How we improved our app startup time by more than 40%

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
0:00
/0:06

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-blown Activity
  • Plays a .riv file with a state machine
  • Waits for two signals: loopComplete from Rive and isSplashScreenDismissed 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:

  1. Native splash screen shows up instantly.
  2. Native modules start initializing.
  3. JS bundle loads quietly in the background.
  4. By the time the splash ends, the home screen is ready, personalized and snappy.

iOS Implementation

  • Triggered preload in AppDelegate using setupRootViewController()
  • 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 animation
  • InteractionManager for async task control
  • Flipper + React DevTools for profiling

Dive into more stories