Mobile Development

    Where React Native Cold Start Time Actually Goes

    Cold start is rarely one slow thing. It is a dozen small ones, and profiling it properly changes which of them you bother to fix.

    “The app takes four seconds to open” is a symptom with at least six independent causes, and the usual response - lazy-load some screens and hope - improves the one that was already fastest.

    Cold start is worth measuring precisely because the distribution of cost is deeply unintuitive.

    Define the phases before measuring anything

    A React Native cold start has four phases, and they fail differently:

    1. Process start. The OS creates the process, loads the native libraries, runs application initialisation.
    2. Runtime init. The JavaScript engine starts and the bundle is loaded and evaluated.
    3. First render. React mounts, the first component tree is built, the bridge or JSI layer creates native views.
    4. First meaningful paint. Data arrives and the screen shows something the user wanted.

    Users experience the sum. Engineers usually optimise phase three because it is the one written in the language they think in. It is frequently the smallest.

    Get real numbers on device

    The single most useful measurement is a timestamp taken at process start in native code, compared against one taken in JavaScript when your root component mounts.

    Kotlin
    class MainApplication : Application() {
        companion object {
            var processStartNanos: Long = 0
        }
    
        override fun onCreate() {
            processStartNanos = System.nanoTime()
            super.onCreate()
        }
    }

    Expose that value to JS, and record the delta at mount. Anything else - a stopwatch, a screen recording, a debug build - measures a different app than the one users run.

    Two rules that make the numbers trustworthy:

    • Release builds only. Development builds load the bundle over the network and run without the optimisations that matter. A debug measurement is not a slow version of the real number, it is an unrelated one.
    • Cold means cold. Force-stop the app, and on Android clear it from recents. A warm start skips phases one and two entirely and will tell you everything is fine.

    The bundle is usually the largest single cost

    Every module in the bundle’s require graph that is reachable at startup is evaluated before your first component renders. Not imported lazily - evaluated.

    That means a top-level import of an analytics SDK, a date library with every locale, or an icon set with a thousand entries costs time on every launch, whether or not the first screen uses it.

    The way to find them is a bundle graph, not a guess:

    Shell
    npx react-native bundle \
      --platform android --dev false \
      --entry-file index.js \
      --bundle-output /tmp/main.jsbundle \
      --sourcemap-output /tmp/main.map
    
    npx source-map-explorer /tmp/main.jsbundle /tmp/main.map

    Typical findings, in rough order of how often they appear:

    Cause Typical cost
    Locale data loaded eagerly 80-300 ms
    Icon font / SVG set imported wholesale 50-200 ms
    Analytics and crash SDKs initialised at import time 100-400 ms
    Navigation graph importing every screen 100-250 ms

    The last one is the one people expect, and it is rarely the biggest.

    Defer initialisation without deferring correctness

    The fix for most of the table above is to move work out of module evaluation and into an explicit call after first paint.

    JavaScript
    // Before: runs during bundle evaluation, on every launch.
    import { Analytics } from 'analytics-sdk';
    Analytics.configure({ key: KEY });
    
    // After: the module is still imported, but the expensive part waits.
    import { InteractionManager } from 'react-native';
    
    export function initAnalytics() {
      InteractionManager.runAfterInteractions(() => {
        require('analytics-sdk').Analytics.configure({ key: KEY });
      });
    }

    runAfterInteractions waits for the current animations and touch handling to settle, which in practice means “after the first screen is interactive”. Events that happen before initialisation should be queued, not dropped - a small in-memory buffer flushed on init is usually ten lines and removes the main objection to deferring.

    Native initialisation is not free either

    Phase one belongs to the native side, and it is invisible from JavaScript entirely. The usual offenders:

    • SDKs auto-initialising through a content provider or Application.onCreate. Several popular crash reporters do this by default and can be moved to manual initialisation.
    • Large native libraries linked but unused. They still cost load time.
    • Synchronous disk reads in onCreate - reading a preferences file, checking for a migration, opening a database.

    On Android, adb shell am start -W gives you the total activity launch time, and systrace or the Android Studio profiler will show where inside it the time went. If phase one is over 400 ms, no amount of JavaScript work will make the app feel fast.

    Show something before you have everything

    The last change is presentational and often the most noticeable. A screen that renders its layout immediately and fills in data as it arrives feels dramatically faster than one that waits for a complete response, even when the total time is identical.

    That means the first render should not depend on a network call. Render the structure, render cached data if you have it, and let the fresh data replace it.

    Perceived start time is measured from launch to something, not from launch to everything. The two are often 800 ms apart, and the gap is free.

    What to do first

    If you have one afternoon:

    1. Add the native-to-JS timestamp and get an honest release-build number.
    2. Run the bundle analyser and defer the top three eager imports.
    3. Check for SDKs initialising in Application.onCreate.
    4. Make the first screen render without waiting on the network.

    That sequence covers most of the distance for most apps, and it tells you whether the remaining work is in JavaScript or somewhere you had not been looking.