Debugging React Native Startup Time in 2026: Where the Milliseconds Actually Go
A field guide to profiling and shrinking cold-start time in a New Architecture React Native app — what to measure, which tools lie to you, and the fixes that consistently move the needle.

Every React Native app is fast on the founder's iPhone 15 Pro. It's the mid-range Android on a flaky network, three years old, with 40 other apps installed, where startup time turns into a product problem. This is the tour we give clients when their app takes four seconds to show something useful and nobody can agree on why.
What "startup time" actually means
Before you profile anything, agree on the metric. "Slow app" is not a bug report. There are at least four distinct numbers people conflate:
- Process start → first native frame (splash visible). Owned by the OS and your native init.
- First native frame → JS bundle evaluated. Owned by Hermes, your bundle size, and how much runs at import time.
- JS evaluated → first React commit. Owned by your root component tree.
- First commit → Time To Interactive (TTI). Owned by data fetching, hydration, and whatever you do in
useEffecton mount.
In our experience, teams optimize step 2 for weeks and then discover the real cost was step 4 — a blocking auth refresh call before the first screen renders. Measure all four before you touch code.
The one measurement that matters
We usually add a tiny bridge-free marker using performance.now() in JS and mach_absolute_time / SystemClock.uptimeMillis() on native, logged to a single event bus. The important part isn't the tool — it's that the same clock captures both sides.
// perf.ts
const marks = new Map<string, number>();
export const mark = (name: string) => marks.set(name, performance.now());
export const measure = (from: string, to: string) => {
const a = marks.get(from); const b = marks.get(to);
if (a && b) console.log(`[perf] ${from}→${to}: ${(b - a).toFixed(1)}ms`);
};
// index.js — first line
mark('js:start');
// App.tsx — inside root component, after first render
useEffect(() => { mark('react:firstCommit'); measure('js:start', 'react:firstCommit'); }, []);
Run it on a real low-end device. Simulators lie about startup by 2–5x in both directions depending on your Mac.
The tools worth using in 2026
The React Native profiling story has consolidated. Here's what we reach for, and what we've stopped bothering with.
Hermes sampling profiler
Still the single most useful tool for JS-side startup. Enable it, launch cold, capture the first 3 seconds, and open the trace in Chrome DevTools or the React Native DevTools frontend. You want the flamegraph, not the call tree — startup is sequential and the flamegraph shows you the wall of imports.
What you're looking for: a wide bar labeled evaluateGlobalCode or a specific require() that eats hundreds of milliseconds. That's almost always a library doing work at module scope.
Perfetto for Android, Instruments for iOS
When the JS side looks clean but startup is still slow, the problem is native. Perfetto (via adb shell perfetto) will show you every thread from zygote fork onward. Instruments' "App Launch" template does the same for iOS. This is where you catch things like Firebase Performance initializing synchronously, or an image library warming its cache on the main thread.
React Native DevTools
The unified DevTools that shipped alongside the New Architecture is now the default. The Components panel is fine; the Profiler tab is useful for post-mount work but misleading for startup because it can't see anything before React mounts. Don't judge cold start with it.
What we stopped using
- Flipper. Deprecated, and its overhead skewed startup numbers anyway.
console.timefor anything cross-thread. The bridge is gone in New Arch, but console still isn't a reliable clock across contexts.why-did-you-render. Great for re-renders, not for startup.
The fixes that consistently pay off
After enough audits, patterns emerge. Roughly in order of ROI:
1. Kill top-level imports that do work
The worst offender we see, every single audit: a barrel file that pulls in an analytics SDK, a crash reporter, a feature-flag client, and a translation library — all initialized as side effects of import. Your bundle now runs 400ms of setup before React exists.
Fix: move initialization behind a function you call intentionally, ideally after first paint.
// bad — runs during bundle eval
import { Amplitude } from '@amplitude/analytics-react-native';
export const analytics = new Amplitude(...);
// good — deferred
let _analytics: Amplitude | null = null;
export const getAnalytics = () => {
if (!_analytics) _analytics = new Amplitude(...);
return _analytics;
};
Then call getAnalytics() from an InteractionManager.runAfterInteractions block or a requestIdleCallback shim.
2. Split the initial screen from everything else
Metro's inlineRequires (on by default now) helps, but it doesn't help if your root file statically imports your entire navigation tree. With Expo Router or React Navigation's lazy screen option, only the first route's component should be reachable from the initial module graph. Everything else — settings, profile, deep-linked flows — should be lazy.
Check with npx react-native bundle --dev false --platform ios --entry-file index.js --bundle-output /tmp/b.js --sourcemap-output /tmp/b.map and then run source-map-explorer on the output. If your Settings screen appears in the initial chunk, you have work to do.
3. Delay non-critical native modules
Autolinking is convenient and expensive. Every linked module runs its +load / static initializer on iOS and its package registration on Android. For modules you don't need before first paint (background geolocation, in-app purchases, some analytics), consider Expo's config plugins to conditionally exclude them from debug builds, or wrap them in a native module that lazily instantiates.
On iOS specifically, watch for modules that do keychain reads or file I/O in init. We've seen 200ms disappear by moving a single auth-token read off the launch path.
4. Don't block first paint on network
This is the one nobody wants to hear. If your app shows a spinner until an auth refresh or a config fetch completes, your TTI is bounded by network latency, not code. Render an optimistic shell immediately, hydrate when the response arrives, and handle the auth-expired case as a normal state transition.
5. Use a real splash strategy
expo-splash-screen lets you hold the native splash until you call hideAsync(). Teams misuse this by holding it until "everything is ready," which makes startup feel worse because the user sees a frozen splash. Hide it as soon as your first screen can render something, even if data is still loading. Perceived performance beats measured performance every time.
Native vs React Native: an honest note
A well-tuned Swift or Kotlin app will always cold-start faster than a well-tuned React Native app. The Hermes bytecode still has to load and evaluate; a native binary doesn't. In our measurements the gap is usually 150–400ms on mid-range hardware — noticeable if you're staring at it, invisible in normal use.
That gap is not a reason to rewrite. It is a reason to be disciplined: a React Native app that adds 300ms of avoidable JS work at startup is now a full second behind a native equivalent, and users feel that.
A quick audit checklist
When we walk into a new codebase, this is the first pass, in order:
- Measure cold start on a real low-end device, three runs, median.
- Capture a Hermes sampling profile of the first 3 seconds.
- Bundle-analyze the initial chunk. Note anything over 50KB that isn't React, RN, or your nav library.
- Grep for
newand function calls at module scope in your own code. - List every autolinked native module. For each, ask: is this needed in the first 2 seconds?
- Check whether the first screen waits on network before rendering.
Steps 1–3 take an afternoon. Steps 4–6 are where the wins live.
Where we'd start
If you have a slow app today and one day to spend on it: instrument the four phases with a shared clock, run it on the oldest supported Android device in the office, and capture one Hermes profile. Don't optimize anything yet. You'll almost certainly find that one module — usually analytics, a crash reporter, or a bloated icon library — is responsible for 30–50% of the JS startup cost, and moving it behind a lazy init is a two-line change. Ship that, remeasure, then decide whether the remaining milliseconds are worth chasing. Most of the time, they aren't — and the team can go back to shipping features.
If you'd rather have someone else do the audit, that's the kind of work our mobile team does regularly.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
The React Native New Architecture in 2026: When Fabric and TurboModules Actually Pay Off
Fabric and TurboModules are now the default in React Native. Here's an honest read on which apps benefit, which don't, and what breaks when you flip the switch.

Deep Links That Actually Work: Universal Links, App Links, and Expo Router in 2026
Deep linking looks trivial until a marketing email opens Safari instead of your app. Here's how we wire Universal Links, Android App Links, and Expo Router so links land where they should — every time.

Offline-First React Native in 2026: Choosing Between WatermelonDB, PowerSync, and Plain SQLite
We've shipped offline-first React Native apps on WatermelonDB, PowerSync, and hand-rolled SQLite. Here's how to pick the right one before you've written 40k lines of sync code you'll regret.
