How we improved our app startup time by more than 40%
We cut cold start time by 40% with native Rive splash screens, parallel JS loading, smart caching, and a leaner bundle. The result: a lightning-fast launch that feels instant, responsive, and polished - giving users a great first impression before they even realize they’re waiting.

Why App Startup Time Really Matters
App startup time is a first impression and let’s face it, sometimes the last one too.
If your app takes more than 2-3 seconds to show something meaningful, users might:
- Think it’s broken
- Rage quit
- Uninstall it forever
At Headout, we’re focused on delivering a fast, smooth, and personalized experience right from the first tap. But cold start delays were weighing us down.
So we rolled up our sleeves and cut our app's startup time by 40%. 💪
Here’s how we did it, let’s break it down.
The Final Result: 40% Faster App Launch 🏁
We measured cold start time from the moment the app process kicks off until the home screen is fully interactive, including:
- JS bridge initialization
- API hydration
- Navigation stack readiness
- Personalization logic
Here’s the before/after breakdown:

📏 How we measured:
We timestamped at native app start (onCreate
for Android,application(_:didFinishLaunchingWithOptions:)
for iOS), and stopped the timer once our JS-rendered home screen tab was mounted and interactive.
1. Instant Native Splash with Rive Animations
The Problem
Our old splash screen was written in JS, which meant:
- It only appeared after the JS bridge initialized.
- Users stared at a blank white screen with a logo.
- It felt clunky and unpolished; definitely not a great first impression.
The Goal
We needed something:
- Instant: loads immediately with the app
- Animated: smooth, motion-driven experience
- Decoupled: doesn’t block React Native initialization
What We Built
We moved splash logic entirely to the native layer, using Rive for animations.
iOS
- Custom
SplashViewController
overlays the React Native root view - Plays a
.riv
animation - Listens for a callback from JS to trigger the exit animation and dismiss
Android
- Used a lightweight
DialogFragment
instead of a full-blownActivity
- Plays a
.riv
file with a state machine - Waits for two signals:
loopComplete
from Rive andisSplashScreenDismissed
from JS before cleanly exiting
The result? A native-first, animation-rich splash that loads instantly and hands off smoothly to React Native without delays.
Android snippet
// MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
Rive.init(this)
super.onCreate(null)
setupRiveFragment()
}
private fun setupRiveFragment() {
if (!isSplashScreenDismissed && supportFragmentManager.findFragmentByTag(SPLASH_FRAGMENT_TAG) == null) {
supportFragmentManager.beginTransaction()
.add(android.R.id.content, SplashFragment(), SPLASH_FRAGMENT_TAG)
.commit()
}
}
// core snippet from the fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
riveView = view.findViewById(R.id.rive_view)
setupRiveAnimation()
}
private fun setupRiveAnimation() {
...
riveView.registerListener(object : RiveFileController.Listener {
override fun notifyAdvance(elapsed: Float) {
if (hasLoopCompletedOnce && isSplashScreenDismissed && !hasDismissSplashStarted) {
dismissSplashScreen()
hasDismissSplashStarted = true
}
}
})
riveView.addEventListener(object : RiveFileController.RiveEventListener {
override fun notifyEvent(event: RiveEvent) {
if (event.name == "loopComplete") {
hasLoopCompletedOnce = true
}
}
})
}
iOS snippet
// RootViewController.swift
override func viewDidLoad() {
super.viewDidLoad()
showSplashScreen()
setupReactNativeApp()
}
private func showSplashScreen() {
splashVC = SplashViewController()
splashVC?.onDismiss = { [weak self] in
self?.removeSplashScreen()
}
...
}
// core snippet from the controller
override func viewDidLoad() {
super.viewDidLoad()
riveVM = SplashRiveViewModel()
riveVM.onFinished = { [weak self] in
self?.onDismiss?()
}
let riveView = riveVM.createRiveView()
view.addSubview(riveView)
riveView.frame = view.bounds
}
override func player(didAdvanceby seconds: Double, riveModel: RiveRuntime.RiveModel?) {
...
}
2. Preloading the JS Bridge Early
Our app does a lot at launch - push notifications, analytics, payments, you name it. Previously, we waited for these native tasks before booting React Native. That was... not ideal.
🚀 What We Changed
We now preload the JS bridge in parallel with native startup.
🔁 New Flow:
- Native splash screen shows up instantly.
- Native modules start initializing.
- JS bundle loads quietly in the background.
- By the time the splash ends, the home screen is ready, personalized and snappy.
iOS Implementation
- Triggered preload in
AppDelegate
usingsetupRootViewController()
- Preloaded the JS bundle inside
RootViewController
before React Native mounted
// AppDelegate.swift
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
setupRootViewController()
// Other native setup here...
return true
}
func setupRootViewController() {
...
self.rootViewController = RootViewController.controller()
...
}
// RootViewController
override func viewDidLoad() {
super.viewDidLoad()
setupReactNativeView()
showSplashScreen()
DispatchQueue.global().async { [weak self] in
self?.setupMainViewController()
}
}
private func setupReactNativeView() {
ReactNativeManager.shared.preloadView(for: self.moduleName, initialProperties: params)
}
This approach ensures React Native is ready before the user leaves the splash screen, delivering a smooth, performant transition.

3. Smart Caching for Personalization
Even with the bridge ready, fetching user data can be a bottleneck especially on flaky networks.
💡 Our Approach:
- Cached critical API responses locally
- Stored remote config and personalization flags
- Rendered the home screen from cache, then hydrated it silently in the background
This gave users the illusion of instant load even when the network wasn’t cooperating.
4. Slimming Down the JS Bundle
A bloated JS bundle doesn’t just hurt performance; it destroys startup time.
🪓 What We Did:
- Used
react-native-bundle-visualizer
to inspect the bundle - Removed dead code, legacy utils, and unused libraries
- Swapped out heavy libraries for lighter alternatives
- Imported only what we needed (no more full-package imports)
☁️ Bonus Win: Moving Images to the Cloud
We found that static images (many unused) were bloating our bundle. So we:
- Offloaded non-critical images to a CDN
- Loaded them on-demand via optimized URLs
- Kept only essential assets in the local bundle
The result? A leaner bundle that parses faster and consumes less memory.
5. Cleaning Up the JS Call Stack
It’s not just what you load, but how you load it.
🔍 What We Found:
- Expensive synchronous tasks were blocking UI rendering
- Background setup (like analytics) was hogging the JS thread
- Poorly optimized async flows delayed screen hydration
🧠 What We Did:
- Deferred non-blocking logic using
InteractionManager.runAfterInteractions
- Batched background tasks after the first screen appeared
- Moved some initialization to the native layer
- Refactored promises and
useEffect
calls for better parallelism
This cleaned up our call stack and ensured our JS thread stayed snappy and responsive.
TL;DR: Our App Now Starts 40% Faster 🚀
Through a mix of native-native-native engineering, JS optimization, and some good old-fashioned bundle cleanup, we made cold starts feel... warm.
Key Wins:
✅ Native splash screen with smooth Rive animation
✅ JS bridge loads in parallel with native init
✅ Personalized home screen appears instantly via cache
✅ Lighter JS bundle = faster parsing
✅ Streamlined JS execution = quicker responsiveness
🛠️ Tools We Loved:
react-native-bundle-visualizer
Rive
for animationInteractionManager
for async task controlFlipper
+ React DevTools for profiling