A case study on optimizing Android splash screen performance through architectural innovation, including the trade offs you need to know.
Overview
It's the festive season and we see the beautiful splash screens and custom logos on every app. While developing these every Android developer faces the splash screen dilemma: users expect beautiful, branded launch experiences, but Google's native splash screen API has significant limitations. The common solution creating a custom SplashActivity, seems logical but introduces a hidden performance penalty that can make your app feel sluggish and unresponsive.

To address this challenge, I developed a test library named EventSplash, this library implements a non-blocking splash screen approach. The complete implementation and benchmarking code is available on GitHub: fast-splash-experiment.
This case study presents empirical evidence from a controlled experiment comparing traditional activity-based splash screens with an innovative view-based approach. Using conservative, apples-to-apples comparisons, the results show: 90% reduction in page load time, 78% improvement in First Contentful Paint and 41% faster Fully Painted Time.
We'll explore the dramatic benefits possible with complex animations like Lottie, while being transparent about the trade-offs and resource costs of concurrent processing.
Mini-Glossary
- First Contentful Paint (FCP): Time until the first meaningful content appears on screen
- Fully Painted Time (FPT): Time until the screen is completely rendered and interactive
- Cold Start: App launch when the process isn't running (highest performance impact)
- Jank: Stuttering or dropped frames that users perceive as poor performance
- TTID/TTFD: Time to Initial Display / Time to Fully Drawn (official Android metrics)
- Memory Pressure: System state when available memory is critically low
- Low Memory Killer (LMK): Android daemon that terminates processes under memory pressure
- Choreographer.doFrame: Android's frame coordination system that manages animations, input and drawing
The Hidden Cost of Beautiful Splash Screens
The Problem Statement
Google's native Android 12+ SplashScreen API provides excellent performance but limited customization options [1]. It doesn't support:
- Video backgrounds
- Lottie animations
- Complex branding elements
- Promotional content during sales/events
- Custom transition effects
This forces developers toward custom implementations, typically using a dedicated SplashActivity
. While this approach offers creative freedom, it creates a blocking sequence that delays the main app content.
Why Traditional Splash Activities Hurt Performance
The Android documentation emphasizes that apps should optimize for cold starts, as this "can improve the performance of warm and hot starts as well" [2]. However, traditional splash implementations work against this principle.
When you use a separate SplashActivity
, the system must:
- Create and initialize the splash activity
- Inflate splash screen views
- Run splash animations to completion
- Destroy splash activity
- Create and initialize main activity
- Inflate main content views
This sequential process means your main content cannot begin loading until the splash completes, a fundamental architectural flaw that impacts user-perceived performance.
The Journey: Exploring Different Approaches
Before arriving at the final EventSplash implementation, I explored several approaches. Understanding these explorations provides valuable context for the final design decisions and demonstrates the iterative nature of performance optimization.
Discarded Approach: The Translucent Activity Overlay
One initial idea was to use a SplashActivity
with a translucent theme to overlay the MainActivity
. The theory was that the MainActivity
could load in the background while the splash screen was displayed on top.
Launch Sequence:
- The application starts with a
SplashActivity
that has a translucent theme - This activity is displayed on top of the
MainActivity
without completely obscuring it - After a short delay or once initializations are complete, the
SplashActivity
is finished, revealing theMainActivity
Why it was discarded:
This approach resulted in a 14% performance degradation. The issue lies in how Android handles activity lifecycles and rendering. The system does not truly launch both activities in parallel. Instead, it creates a sequential dependency and the GPU is forced to compose two separate activity buffers, which incurs significant overhead in terms of RAM, battery and sometimes even disables window transition animations.
As noted in the Android documentation on translucent activities [3]:
"Window Manager keeps the previous surface in Z-order and blends the new one above it. Previous activity is still visible through any pixels that are transparent or partially-transparent in the new window."
This blending operation is exactly what caused the performance degradation.
The Winning Approach: The Gated Splash Screen Mechanism
I ultimately landed on a more sophisticated approach that involves a gated splash screen mechanism. This method uses a ViewTreeObserver.OnPreDrawListener
to block all UI rendering until specific conditions are met.
How it works:
- An
OnPreDrawListener
is attached to the activity'sdecorView
immediately on startup - The listener's
onPreDraw()
method returnsfalse
, effectively blocking all drawing - The listener only returns
true
when all conditions are satisfied, allowing the content to be rendered
Key Implementation:
// The gate mechanism
gate.onPreDraw() → returns false = BLOCK all drawing
gate.onPreDraw() → returns true = ALLOW drawing to proceed
This approach aligns perfectly with the official Android documentation recommendation for keeping splash screens on-screen longer [1]:
"If you need to load a small amount of data, such as loading in-app settings from a local disk asynchronously, you can use ViewTreeObserver.OnPreDrawListener to suspend the app to draw its first frame."
The EventSplash library extends this concept to provide frame-perfect control over what the user sees during app startup, preventing any content flash and ensuring a seamless experience.

The Experiment: Measuring Real-World Impact
Test Environment
- Device: Xiaomi POCO F1, Android 10
- Build: Release configuration
- Methodology: 35 cold launches per configuration, 2-second pause between runs
- Metrics: Custom PerfTracker library measuring Page Load, FCP, and FPT
- Script: Automated via
perf_loop.sh
for reproducibility
All testing code and scripts are available in the GitHub repository for reproducibility.
Implementation Approaches Tested
- Default Blocking Splash: Simple
SplashActivity
with basic routing (conservative baseline) - Default Non-Blocking Splash: EventSplash library with simple overlay
- Lottie Blocking Splash: Traditional approach with complex animation
- Lottie Non-Blocking Splash: EventSplash with Lottie animation running in parallel
Results: Conservative Claims with Dramatic Potential

The Honest Comparison: Default Splash Performance
For apples-to-apples comparison, we focus on the default splash implementations where the blocking approach simply inflates an activity for routing purposes:
Approach | Page Load (ms) | FCP (ms) | FPT (ms) | User Impact |
---|---|---|---|---|
Default Blocking | 366 | 744 | 2,195 | Noticeable delay |
Default Non-Blocking | 37 | 164 | 1,295 | Smooth, responsive |
Improvement | 90% | 78% | 41% | Significant |
The Lottie Animation Advantage
When we introduce complex Lottie animations, the architectural difference becomes even more pronounced:
Approach | Page Load (ms) | FCP (ms) | FPT (ms) | Notes |
---|---|---|---|---|
Lottie Blocking | 2,228 | 2,347 | 3,524 | Includes animation duration |
Lottie Non-Blocking | 109 | 312 | 1,467 | Animation runs in parallel |
Improvement | 95% | 87% | 58% | Dramatic |
Understanding the Lottie Numbers
Important Note: The blocking Lottie numbers include the animation duration by design, the user must wait for the entire animation to complete before seeing any main content. In the non-blocking approach, both the animation and content loading run in parallel, so by the time the Lottie animation finishes, the FPT is typically already complete or nearly complete.
This parallel execution is the key architectural advantage: you get beautiful animations without sacrificing performance.
Performance Improvements Breakdown

Recap: What Happened
Even with conservative default splash comparisons, the non-blocking approach achieved 90% faster page load times. The user experience transformation is from "noticeable delay" to "smooth and responsive."
With complex animations like Lottie, the benefits become even more dramatic because the traditional approach forces users to wait for the entire animation sequence before any meaningful content appears.

Why: The Technical Mechanism
The performance gains stem from parallel execution. While the traditional approach runs splash and main content sequentially, the view-based approach runs them concurrently:
Traditional (Sequential):
Splash Activity → Animation → Destroy → Main Activity → Content Load → Display
Non-Blocking (Parallel):
Main Activity + Content Load (background)
↓
Splash View (overlay) → Remove overlay → Display loaded content
This architectural difference eliminates the blocking bottleneck entirely.
Deep Dive: Understanding the Technical Implementation
Traditional Splash Activity Implementation
class SplashActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
installSplashScreen()
enableEdgeToEdge()
setContent {
Loader { // Blocks until animation completes
startActivity(Intent(this@SplashActivity, MainActivity::class.java))
}
}
}
@Composable
fun Loader(onComplete: () -> Unit) {
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.sale_tags))
val progress by animateLottieCompositionAsState(composition)
// Animation blocks main content loading
if (progress == 1.0f) {
onComplete.invoke()
}
}
}
EventSplash: Non-Blocking Implementation
class EventSplash(
private val activity: ComponentActivity,
private val config: EventSplashConfig,
) {
private val decorView: ViewGroup = activity.window.decorView as ViewGroup
private var composeView: ComposeView? = null
// Gate prevents premature display until main content ready
private val gate = object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
return if (isReady) {
decorView.viewTreeObserver.removeOnPreDrawListener(this)
true
} else false
}
}
init {
decorView.viewTreeObserver.addOnPreDrawListener(gate)
setupSplashCompose() // Non-blocking overlay
isReady = true
}
private fun setupSplashCompose() {
val view = ComposeView(activity).apply {
layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
setContent {
getProvider(config).Content(onFinish = { dismiss() })
}
}
composeView = view
decorView.addView(view) // Overlay on main content
}
}
Usage Comparison
Traditional Approach:
// Requires separate activity, blocks main content
class MainActivity : ComponentActivity() {
// Main content only loads after splash completes
}
EventSplash Approach:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Non-blocking: splash displays while content loads
EventSplashApi.attachTo(this).with(getSaleConfig()).show()
setContent {
// Main content loads immediately in parallel
MainAppContent()
}
}
}
Recap: Implementation Differences
The traditional approach requires a separate activity lifecycle, while EventSplash injects a view overlay that coexists with the main content loading process.
Why: Architectural Advantages
- Single Activity Context: Eliminates activity transition overhead
- Parallel Processing: Main content loads while splash displays
- Reduced Memory Footprint: No duplicate activity objects
- Fewer Choreographer.doFrame Cycles: Less rendering pipeline stress
- Optimized View Hierarchy: Single decoration view instead of separate activities
The Choreographer.doFrame Problem
Understanding Frame Rendering Issues
Android's rendering system relies on Choreographer.doFrame
to coordinate animations, input, and drawing [4]. The documentation warns:
"If Systrace shows that the Layout segment of Choreographer#doFrame is working too much or working too often, this means you're hitting layout performance issues"
Why Splash Activities Cause Jank
Traditional splash implementations create multiple performance bottlenecks:
- Double Layout Passes: Each activity requires separate view inflation and layout
- Context Switching Overhead: OS must manage multiple activity contexts
- Memory Pressure: Duplicate view hierarchies consume additional RAM
- Frame Timing Issues: Activity transitions trigger extra doFrame cycles
Perfetto Analysis Insights
When analyzing traces with Perfetto, traditional splash screens show:
- Extended
Choreographer.doFrame
execution times - Multiple layout inflation spikes
- Increased garbage collection pressure
- Delayed main thread availability
The view-based approach eliminates these issues by maintaining a single rendering context throughout the startup process.
The Other Side of the Coin: Hidden Costs and Trade-offs
⚠️ Critical Consideration: Concurrent Processing Isn't Free
While our results show significant performance improvements, the non-blocking approach introduces its own set of challenges that must be carefully considered. Running splash animations concurrently with main content loading creates additional resource pressure that doesn't exist in sequential approaches.
Memory Pressure: The Primary Concern
Peak Memory Usage Increase:
// Memory usage pattern comparison
Traditional Approach:
Splash: 50MB → 0MB → Main Content: 120MB = Peak: 120MB
Non-blocking Approach:
Splash + Main Content: 50MB + 120MB = Peak: 170MB
Real-world Impact:
- Simple splash overlays add 20-50MB during concurrent execution
- Lottie animations can consume 50-100MB+ during rendering
- Combined peak usage can be 40-70% higher than sequential approaches
- Low-end devices (1-2GB RAM) become vulnerable to memory pressure
Low Memory Killer Risk
Android's Low Memory Killer daemon monitors system memory and can terminate apps under pressure [5]:
"Memory pressure, a state in which the system is running short on memory, requires Android to free memory by throttling or killing unimportant processes"
Risk Factors:
- App process termination during startup creates terrible UX
- Background apps killed more aggressively
- Memory fragmentation from concurrent allocations
- Particularly problematic on budget devices
CPU and Battery Implications
Increased CPU Overhead:
Choreographer.doFrame
handles multiple concurrent operations- Main thread becomes busier with overlapping UI work
- GPU rendering pipeline processes both splash and content simultaneously
Power Consumption Concerns: Research shows that "UI rendering on smartphones needs powerful CPU and GPU to meet user-perceived smoothness, and it contributes a significant portion of energy consumption" [6].
Device Compatibility Challenges
Low-End Device Considerations:
- Single-core or dual-core processors struggle with parallelization
- Limited RAM makes memory pressure critical
- Slower storage compounds loading delays
- Benefits may not translate to budget devices
When NOT to Use Non-Blocking Approach
Scenarios Where Traditional Approach May Be Better:
- Extremely resource-constrained devices (< 2GB RAM)
- Battery-critical applications where power consumption is paramount
- Simple splash screens without complex animations
- Apps with heavy startup processing that already stress the system
- Legacy codebases where refactoring risks outweigh benefits
Risk Mitigation Strategies
Adaptive Implementation:
class AdaptiveSplashStrategy {
fun chooseSplashApproach(): SplashConfig {
return when {
isLowEndDevice() -> SimpleSplashConfig()
isBatteryLow() -> ReducedAnimationConfig()
isHighPerformanceDevice() -> FullLottieConfig()
else -> DefaultConfig()
}
}
private fun isLowEndDevice(): Boolean {
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
return activityManager.isLowRamDevice ||
Runtime.getRuntime().maxMemory() < 256 * 1024 * 1024
}
}
Memory Monitoring:
private fun monitorMemoryPressure() {
val memoryInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memoryInfo)
if (memoryInfo.lowMemory) {
// Fallback to simpler splash
simplifyOrDismissSplash()
}
}
Industry Context and Validation
Alignment with Industry Best Practices
The EventSplash approach aligns with recent industry trends and official recommendations. Companies like Turo have achieved similar results by eliminating dedicated splash activities. As reported in their case study [7]:
"Initially, we used a dedicated SplashActivity to run all the startup work before routing the app to the HomeActivity. However, the latest guidelines advise against this approach. Therefore, we eliminated the redundant SplashActivity and transferred all the startup logic to our root activity."
Turo achieved a 77% reduction in startup time using similar principles.
Validation Through Official Documentation
The approach is further validated by the official Android documentation's explicit recommendation to use ViewTreeObserver.OnPreDrawListener
for splash screen management [1], which is exactly what EventSplash implements at its core.
Best Practices and Common Pitfalls
Do's ✅
- Use view-based splash implementations for custom animations on capable devices
- Implement adaptive strategies based on device capabilities
- Measure performance with real devices and release builds across device tiers
- Monitor memory usage and implement leak prevention
- Optimize for cold starts as the worst-case scenario
- Test extensively on low-end devices to ensure broad compatibility
- Implement proper lifecycle management for splash views
Don'ts ❌
- Don't assume one-size-fits-all: Device capabilities vary dramatically
- Don't ignore memory pressure: Monitor and adapt to system constraints
- Don't use separate SplashActivity without considering alternatives
- Don't block main content loading with splash animations
- Don't ignore Android Vitals metrics in Play Console
- Don't test only on high-end devices or debug builds
- Don't create complex view hierarchies in splash screens
- Don't perform heavy operations during splash display
- Don't forget to clean up splash views and clear caches
Common Pitfalls
- Memory Leaks: Failing to clear LottieCompositionCache
- Device Capability Assumptions: Not adapting to low-end device constraints
- Lifecycle Issues: Not handling activity state changes properly
- Animation Conflicts: Splash animations interfering with main content
- Testing Bias: Only testing on fast devices or debug builds
- Metric Misunderstanding: Focusing on animation duration instead of user-perceived performance
- Resource Monitoring Neglect: Not monitoring memory and CPU usage patterns
Making Informed Architectural Decisions
Decision Framework
When choosing between splash screen approaches, consider:
Device Demographics:
- What percentage of your users have low-end devices?
- What's your minimum supported RAM configuration?
- Are you targeting emerging markets with budget devices?
App Characteristics:
- How complex is your main content loading?
- Do you have heavy network dependencies?
- What's your current memory footprint?
Business Requirements:
- How important are custom splash animations to your brand?
- Can you implement progressive enhancement?
- What's your development and testing capacity?
Recommended Strategy
Progressive Enhancement Approach:
EventSplashApi.attachTo(this)
.withFallback(SimpleSplashConfig()) // Low-end devices
.withStandard(ImageSplashConfig()) // Mid-range devices
.withEnhanced(LottieConfig()) // High-end devices
.adaptToDevice() // Automatic selection
.show()
This approach provides:
- Baseline functionality for all devices
- Enhanced experience where resources allow
- Automatic adaptation to device capabilities
- Graceful degradation under memory pressure
Insights & Recommendations
The non-blocking splash screen approach offers significant performance improvements (90% faster page load in conservative testing, up to 95% with complex animations), but it's not without trade-offs. Concurrent processing increases peak memory usage and CPU overhead, which can be problematic on low-end devices.
Key insight: The benefits are substantial and measurable, but they come with resource costs that must be managed through adaptive implementation strategies.
Honest recommendation: Use the non-blocking approach with device-aware fallbacks. Even conservative estimates show dramatic performance gains and the architectural advantages are compelling. However, the implementation must be sophisticated enough to handle the full spectrum of Android devices.
Conclusion
This case study demonstrates that performance optimization requires balancing competing constraints while maintaining honest claims. The non-blocking, view-based approach offers substantial, measurable benefits, but successful implementation demands a deep understanding of both the gains and the costs.
By moving away from the traditional SplashActivity
pattern and embracing a more sophisticated, concurrent architecture, we can build faster, more responsive Android apps that work reliably across the entire ecosystem.
The goal is not just building faster apps, but building apps that feel instantaneous and delightful to use, because in the end, performance is a feature that users notice and appreciate.
References
- Splash screens | Views | Android Developers
- App startup time | App quality | Android Developers
- The Android Lifecycle cheat sheet — part IV : ViewModels, Translucent Activities and Launch Modes | by Jose Alcérreca
- Slow rendering | App quality | Android Developers
- Low-memory killer daemon (lmkd) | Android Open Source Project
- Mobile UI Rendering Power Consumption Research
- Turo reduced its app startup time by 77% using Android Developer tools and best practices