Skip to content
OTFotf
All posts

React Native cold start drops when Hermes, lazy screens, and trim bundles ship together

D
DaveAuthor
10 min read
React Native cold start drops when Hermes, lazy screens, and trim bundles ship together

Cold start is the first promise your app makes. A user taps the icon, the splash shows, and then there is a gap — sometimes half a second, sometimes three — before anything usable appears. Most of that gap is not your code running slowly. It is your code loading, parsing, and doing too much before first paint. AI-built apps feel this more than most, because generated code tends to import everything, register every screen up front, and run setup work in the launch path that could wait.

The good news is that cold start is one of the most measurable problems in mobile work. You can time it on a real device, change one thing, and time it again. This guide walks through the four fixes that move the number the most for React Native apps: confirming Hermes bytecode, lazy-loading navigation screens, trimming the bundle that blocks first paint, and deferring everything else past it. Each one is shippable this week, and together they usually cut cold start by a third or more on mid-range Android, which is where your slowest users live.

Measure cold start before you fix it

Do not guess. Release builds behave nothing like debug builds — debug loads from the packager with dev overhead, while release runs precompiled bytecode on device. Every measurement below assumes a release build on a physical device, ideally a mid-range Android phone rather than a flagship, because flagships hide the problems your real users feel.

Start with the simplest timing loop: build release, force-stop the app, launch it cold, and record the time from tap to interactive screen with a stopwatch or screen recording at 60fps. Do it five times and take the median. That is your baseline. Write it down somewhere the whole team can see, because every later fix gets judged against it.

# Android release run on a connected device
npx react-native run-android --mode=release

# iOS release run (Release scheme)
npx react-native run-ios --configuration Release

Next, find out what loads. The bundle analyzer for your setup — Metro has community visualizers, and most teams can get far by logging which screens and SDKs initialize at launch — tells you which imports run before first paint. In AI-generated codebases the usual suspects are analytics, crash reporting, feature-flag SDKs, OTA update checks, and font loading, all initialized synchronously in the entry file. You do not need to remove any of them. You need to know their order and their cost, so the deferral step later is deliberate instead of hopeful.

One more baseline habit: measure on the same device, same OS version, same network state every time. Cold start mixes disk, CPU, and network, and a warm cache or a fast office network can fake a full second of improvement. Airplane mode with cached data versus fresh install are two different numbers — record both, and optimize the fresh-install path first, because that is the new-user experience that decides retention.

Hermes bytecode is the default win

Hermes is the JavaScript engine that ships by default with React Native, and it exists largely to make startup faster. Instead of parsing a large JavaScript bundle on device at launch, Hermes runs precompiled bytecode produced at build time. The React Native docs describe the payoff as improved start-up time, decreased memory usage, and smaller app size compared with JavaScriptCore, and confirm that recent React Native versions enable Hermes by default with no extra configuration (https://reactnative.dev/docs/hermes).

The most common failure here is not disabling Hermes — it is never confirming it. Non-standard bundle loading, custom build scripts, or an old config carried forward by an AI assistant can silently run without the optimized bytecode path even when the Hermes flag looks enabled. Verify with the documented global in a release build:

// Returns true when Hermes is the active engine
const isHermes = (): boolean => !!global.HermesInternal;

if (__DEV__) {
  console.log('Engine:', isHermes() ? 'hermes' : 'not-hermes');
}

The docs add one caution worth repeating: the global can exist even when the bundle is not actually loading the highly optimized precompiled output, so confirm you are shipping the compiled bytecode file and benchmark release before and after. If your build pipeline was set up by an agent, check the Android and iOS build configs explicitly rather than trusting the template default — generated projects sometimes pin old settings.

For most teams this step is verification, not migration. That is exactly why it belongs first: it costs an hour, it rules out the most embarrassing cause of a slow launch, and it gives you a clean foundation for the navigation and bundle work that follows. If Hermes was somehow off, turning it on is usually the single biggest cold-start improvement available, and it comes with smaller memory use as a bonus.

One codebase. iOS, Android, and web.

The Fitness Kit ships with auth, a database, and a backend already connected — no setup. Live demo at fitness-preview.otf-kit.dev.

See the live demo

Lazy screens beat eager navigation

After the engine, the biggest launch cost is screens. Generated apps typically register every screen in the navigator at startup and import every screen file eagerly, which means the launch path parses code for settings pages, paywalls, onboarding flows, and admin panels the user may never open. Lazy loading flips this: only the initial route loads before first paint, and everything else loads when the user navigates to it.

With React Navigation, the pattern is straightforward. Keep static imports for the initial tab or stack, and lazy-load the rest with React.lazy or the navigator's lazy options so secondary screens mount on first visit rather than at launch:

import * as React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';

// Eager: initial route only — this blocks first paint, keep it lean
import HomeScreen from './screens/home';

// Lazy: everything else loads on first navigation, not at launch
const SettingsScreen = React.lazy(() => import('./screens/settings'));
const PaywallScreen = React.lazy(() => import('./screens/paywall'));
const OnboardingFlow = React.lazy(() => import('./screens/onboarding'));

const Stack = createNativeStackNavigator();

export function RootNavigator(): React.JSX.Element {
  return (
    <Stack.Navigator>
      <Stack.Screen name="Home" component={HomeScreen} />
      <Stack.Screen name="Settings" component={SettingsScreen} />
      <Stack.Screen name="Paywall" component={PaywallScreen} />
      <Stack.Screen name="Onboarding" component={OnboardingFlow} />
    </Stack.Navigator>
  );
}

Wrap the navigator in a Suspense fallback so lazy screens show a lightweight placeholder while their chunk loads — a blank view or skeleton, never a spinner that blocks the whole app. Measure the effect the same way you measured the baseline: the win shows up as faster time-to-interactive on the home screen, with a small one-time cost the first time each lazy screen opens. That tradeoff is almost always correct, because most sessions touch only two or three screens.

A related habit for AI-built codebases: audit barrel imports. An innocent import { Button } from './components' can pull hundreds of components through a single index file at launch. Import from the file, not the barrel, on the launch path — or check whether your bundler inlines them. For a deeper look at keeping lists fast once the app is open, the production guide on high-performance lists pairs well with this work: fast launch means little if scrolling stutters right after.

Trim the bundle that blocks first paint

Whatever remains imported at launch still has to load, so the next step is making that bundle smaller. Three trims cover most apps. First, remove dead dependencies — generated projects accumulate date libraries, icon packs, animation frameworks, and polyfills that one abandoned screen used three months ago. If nothing imports it, uninstall it; bundle size is a launch metric, not just a download metric.

Second, replace heavy libraries on the launch path with lighter ones. A full date-time library to format one timestamp, an entire utility belt imported as import _ from 'lodash' instead of per-function imports, a charting framework loaded for a chart behind a tab — each adds parse time before first paint. Swap to date-fns style per-function imports or platform APIs, import utilities per function, and move the chart library behind the same lazy boundary as its screen.

Third, split what you cannot remove. Anything needed eventually but not at launch — onboarding assets, language packs, large JSON fixtures, ML models — should load after first paint or on demand, not ship inside the initial bundle. On Expo and bare React Native alike, asset bundling configs decide what rides along at startup; generated configs tend to include everything in the project folder.

# Find the heaviest launch-path dependencies (example audit)
npx depcheck
npx react-native-bundle-visualizer --only-main-bundle

Treat depcheck output as a starting list, not a verdict — AI-generated code sometimes uses dynamic imports the checker misses. Confirm each removal with a release-build timing run, because the goal is faster cold start on device, not a smaller number on a dashboard. One caution from production experience: trimming crash reporting or error boundaries to save kilobytes is never worth it. When a trimmed launch crashes in the wild with no reporter attached, you will wish for those kilobytes back — the triage habits in crash triage that ships fixes assume your reporter survives launch.

Defer work past first paint

With the engine confirmed, screens lazy, and the bundle trimmed, the remaining launch cost is work: SDK init, network calls, permission prompts, font loading, migration runs, and state hydration. The rule is simple — if it is not needed to render the first screen, it does not run before the first screen. InteractionManager.runAfterInteractions on React Native is the standard deferral point, and plain requestAnimationFrame plus small timeouts cover the rest.

A practical launch order looks like this: render the first screen with cached data immediately, then in the deferred phase initialize analytics and crash reporting, hydrate remote config and feature flags, check for OTA updates, sync offline queues, and prefetch the second screen. Users perceive the app as instant because it is interactive while the background work finishes — and on slow networks the app still works from cache instead of staring at a splash screen waiting for a fetch. The offline-first patterns in mutation queues that survive airplane mode are the right companion here: deferred sync only feels safe when queued writes cannot be lost.

Watch for two traps. First, permission prompts requested at launch on both platforms train users to deny — ask when the feature is used, not when the app opens. Second, font and splash-screen APIs that block hiding the splash until fonts load: preload only the weights the first screen uses, and let the rest swap in after. Each deferred item should have an owner and a failure mode — what the app shows if analytics never initializes, if flags never load, if the update check times out. Launch code runs on every session on every device your users own; it deserves the same review care as payment code.

Ship checklist for AI-built apps

Close the loop with a checklist you can run every time an agent touches the launch path. Confirm Hermes bytecode in a release build. Confirm only the initial route is statically imported. Run the dependency audit and justify every launch-path import. Move SDK init behind the deferred phase with cached-first rendering. Then take five cold-start timings on the same mid-range device and compare against the baseline you wrote down at the start.

Make this checklist part of your starter template so future generated screens follow it by default — lazy route wrappers, a deferred-init module, and a documented launch order in the repo readme cost little once and pay off on every feature. Teams building from kits often get this structure for free; if you are assembling your own, browse the current templates for a setup whose navigation and init order already follows these patterns rather than rebuilding them from scratch.

Cold start is never finished, only monitored. OS updates, new SDKs, and each season of feature work add weight back. Re-run the five-timing loop monthly and graph the median — a slow upward drift is the signal to trim again before users notice. The apps that feel fast in a year are not the ones that optimized once; they are the ones that kept launch on a budget and made every agent-generated addition earn its place in the startup path.

Sources

react-nativecross-platformtemplates
OTF Fitness Kit

Stop wiring. Start shipping.

  • Login, database, and backend already connected — nothing to set up
  • iOS + Android + web from one codebase
  • AI configs pre-tuned + 40+ tested prompts included