Cutting React Native Startup Time: What Actually Moved the Needle
A field report from restructuring a production React Native app — which optimizations delivered real startup and runtime wins, which were noise, and how to measure the difference.
When I took over the UTS platform at HeavyTask, the complaint from the field was blunt: "the app takes forever to open." Over a few months of restructuring we cut load times by more than half and improved overall runtime performance by roughly 40%. Most of that came from a handful of changes — and a longer list of things I tried that did almost nothing.
Measure before you touch anything
The first week produced zero code changes. It produced numbers:
- Cold start to first meaningful screen, measured with simple timestamps: one marker in native
onCreate/didFinishLaunching, one when the first real screen finishes its initial render. - JS bundle parse time, visible in the startup trace.
- Slow frames on the three heaviest screens, from the built-in performance monitor.
This matters because startup work is a pipeline: native init → JS bundle load → React tree mount → data fetch → first render. Optimizing a stage that isn't the bottleneck feels productive and changes nothing. Ours was split between JS bundle evaluation and a waterfall of blocking work on the first screen.
The big three wins
1. Stop doing everything at import time.
The app's entry point imported the entire feature surface — every screen, every service, every third-party SDK — before rendering a single pixel. Module evaluation is synchronous; every top-level import with side effects is startup tax. The fixes were unexciting and effective:
- Lazy-register heavy screens so their modules load on first navigation, not at boot.
- Defer SDK initialization (analytics, crash reporting, push) until after the first screen is interactive. A
requestIdleCallback-style "after interaction" queue is enough:
import { InteractionManager } from "react-native"
InteractionManager.runAfterInteractions(() => {
initAnalytics()
initPushNotifications()
})
- Kill barrel files on hot paths. An innocent
import { formatDate } from "@/utils"was pulling in the whole utility folder, including a charting library.
2. Fix the first-screen data waterfall.
The home screen fetched user profile → then permissions → then dashboard data, each request starting only after the previous finished. Three sequential round-trips on Iraqi mobile networks is a visible pause. Collapsing them into one parallel batch (and caching the previous session's data for instant first paint, then revalidating) turned "loading…" into "there's my data" for the common case. This one change was worth more than every micro-optimization combined.
3. Give the lists a budget.
The heaviest screens were long lists doing too much per row: inline closures creating new props every render, images at full resolution scaled down in the view, and a renderItem tree eight components deep. The playbook:
- Memoize row components and keep
renderItemreferentially stable. - Request images at display size — the CDN was happy to resize; the phones were not.
- Flatten the row component tree. Depth is not free on low-end Android.
Things that didn't matter (for us)
Honesty section. These are popular advice and did little here:
- Replacing
useStatewith refs everywhere. Micro-noise compared to the data waterfall. - Obsessive
useMemoon cheap computations. Memoization has its own cost; profile first. - Switching state libraries. The problem was when work happened, not which library scheduled it.
None of these are bad ideas universally — that's the point. Performance work is local. The wins above were our wins because the measurements pointed there.
Keep it fast: performance as a release gate
The regression risk after an optimization project is that the numbers quietly decay. Two cheap guards:
- The cold-start measurement runs in every QA pass on a low-end Android reference device — the phone your users actually own, not your dev machine's simulator.
- Any PR adding a dependency to the entry path has to say so. Most startup regressions arrive as one line in
package.json.
The meta-lesson from this project: users don't experience your architecture, they experience the gap between tapping the icon and seeing their data. Spend your effort on that gap, in measured order, and the big numbers follow.