Mobile Apps

Cutting cold start time in React Native apps

Target the slowest phase of a React Native app launch with bundle preloading, Hermes flags, and lazy initialization to drive measurable cold start improvement.

Mohammed Saqib8 min read
From above crop faceless male developer in black hoodie writing software code on netbook while working in light studio
Photo by Sora Shimazaki on Pexels · Pexels License

Every cold start in a React Native app runs through three sequential phases: native framework initialization, JavaScript bundle parsing and execution, and the first render to a user-interactable frame. For an app using Hermes, the bytecode compilation moves to build time, but decompression and initialisation still add measurable delay. The following sections target the slowest of these phases with concrete, measurable changes.

What cold start actually includes

A cold start begins when the user taps the app icon and ends when the first frame that accepts touch events appears on screen. Splash screen dismissal is not the finish line — the finish line is when AppRegistry.runApplication() has completed and the root component has mounted and painted its first interactive elements.

The three phases are:

  1. Native init – The OS loads the app binary, the UIApplication or Activity starts, and React Native’s native modules register themselves in the bridge. Every native module that is linked (whether used or not) triggers +load or onCreate at this stage.
  2. JS bundle parse/execute – The JavaScript engine (Hermes, JSC, or V8) reads the bundle, parses it into bytecode or AST, and executes top-level code. With Hermes, the bytecode is precompiled at build time, but decompression of the .hbc file still costs CPU cycles proportional to bundle size. Initialisation of global state (polyfills, module registrations, static initialisers) happens here.
  3. First paint – React runs render() for the root component, reconciles the virtual DOM, and the native layer flushes the UI operations. The user sees the first frame, but interactivity may still be blocked if event handlers are not yet attached.

A common mistake is to treat “app launched” as the end of cold start. In practice, if a user can see a splash screen but cannot tap a button for another 300 ms, the cold start is still ongoing.

Inline requires and Metro’s module graph

Metro bundles modules lazily by default: a require() call loads the module at runtime only when the call executes. This keeps the initial bundle small, but it also forces the engine to parse and evaluate the dependency graph on demand, which can add small pauses during the first render.

The inlineRequires option changes this behaviour. When set to true in metro.config.js, Metro inlines the require() calls for the modules you specify into the initial chunk, so they are parsed and evaluated immediately as part of the bundle execution.

// metro.config.js
module.exports = {
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: true, // enable for all modules
      },
    }),
  },
};

You can verify the effect by generating a bundle report:

npx react-native bundle --platform ios --dev false --entry-file index.js --bundle-output /tmp/bundle.js --assets-dest /tmp/assets --verbose

Look for the module graph depth. With inlineRequires: true, the initial chunk includes more modules, but the depth of lazy require chains decreases. The trade-off is bundle size: if you inline every module, the initial bundle can bloat and increase parse time beyond the savings. Target only modules that are definitely used in the first rendered component tree. For example, your root navigation container, the first screen’s component, and any synchronous dependencies (theme, i18n initialisation) are good candidates. Leave non-critical modules (analytics, push notifications) lazy.

Preloading the JS bundle before the boot splash

By default, React Native loads the JS bundle after the native splash screen (or the initial white screen) appears. The bundle is either included in the native binary (via react-native bundle) or fetched from a local Metro server during development. In production, the bundle is embedded, but it still must be read from disk and decompressed.

An alternative is to delay the call to AppRegistry.runApplication() until the bundle is fully ready, and show a native splash screen in the meantime. This prevents the user from seeing a blank white screen while the JS engine is initialising.

On iOS, you can set a launch screen storyboard as the default, then signal JS readiness from the native side:

// AppDelegate.m
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  self.moduleName = @"YourApp";
  self.initialProps = @{};
  // Do not call [super application:...] yet; instead load the bundle manually
  return YES;
}
 
// When the JS bundle is ready (e.g., after preloading in a background thread):
- (void)jsBundleDidLoad {
  [super application:[UIApplication sharedApplication] didFinishLaunchingWithOptions:nil];
}

On Android, you can use a similar pattern by overriding ReactActivity’s getLaunchOptions and calling loadApp after the bundle is ready.

A more robust strategy is to embed the bundle in the native binary and read it into memory before the first frame. This avoids network fallbacks and ensures the bundle is always available. The splash screen acts as a placeholder while the engine boots. Pair this with a timeout: if the bundle fails to load within a few seconds, fall back to a cached version or show an error state.

Lazy initialization of non-critical services

Many apps initialise heavy modules at the top level of index.js or in the root component’s constructor. Common culprits include internationalisation (i18n) libraries, analytics SDKs, crash reporters, and push notification handlers. Each of these can add 100–300 ms to the cold start if their initialisation code is synchronous.

Defer them until after the first render using a custom hook that fires after requestAnimationFrame:

// useAppReady.js
import { useState, useEffect } from 'react';
 
export function useAppReady() {
  const [ready, setReady] = useState(false);
 
  useEffect(() => {
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        setReady(true);
      });
    });
  }, []);
 
  return ready;
}

Then in your root component:

function App() {
  const ready = useAppReady();
 
  useEffect(() => {
    if (ready) {
      Analytics.initialize();
      CrashReporter.start();
      PushNotifications.register();
    }
  }, [ready]);
 
  if (!ready) {
    return <SplashScreen />;
  }
 
  return <MainNavigator />;
}

The double requestAnimationFrame ensures the initial layout and paint have completed before the heavy initialisation runs. The trade-off: any service that must capture events before the first screen (e.g., early session analytics) cannot be deferred. In that case, initialise a lightweight shim immediately and defer the full SDK.

Native initialization overhead: the hidden cost

Every npm package with a native module adds a registration call in the app delegate or MainApplication.java. Even if the module is never used in JavaScript, its +load or onCreate still runs during native init. This is especially problematic with autolinking, which links every native module found in node_modules.

Audit the list of linked native modules:

npx @react-native-community/cli platform ios

This prints all native modules registered for the iOS target. For Android, inspect MainApplication.java for getPackages().

Remove unused native modules by editing react-native.config.js:

module.exports = {
  dependencies: {
    'react-native-unused-module': {
      platforms: {
        ios: null,
        android: null,
      },
    },
  },
};

For modules you do need, consider lazy native modules. On iOS, you can delay initialisation from +load to +initialize (or a manual call from JS). This is a technique used by the New Architecture (see Adopting React Native New Architecture: Fabric and TurboModules). On Android, you can override ReactPackage.createNativeModules() to return lazy proxies.

Failure modes: when optimizations backfire

Inline requires that pull in heavy libraries like Moment.js (or any large dependency) can push cold start above baseline because the initial parse time increases. Always measure the bundle size and parse time before and after enabling inlineRequires for a module. If the module is >50 KB, it may not be worth inlining.

Bundle preloading that relies on a local server or network fetch can fail on a slow or unavailable connection. The result is a blank screen that never transitions to the app. Always pair preloading with a timeout fallback that shows the cached splash and attempts a retry. Alternatively, embed the bundle in the binary to remove network dependency entirely.

Lazy initialisation of crash reporters means that crashes during the first 50–100 ms of the app’s lifetime are lost. This is acceptable if you accept the trade-off: the first frame is more important than capturing early crashes. For most apps, the risk is low because the initial render code is well tested.

Optimization Typical gain Risk
Inline requires for critical modules 100–200 ms reduction in first render Bundle size increase, slower parse if overused
Bundle preloading with native splash 200–400 ms reduction in perceived start Blank screen on failure; requires native changes
Lazy initialisation of non-critical services 100–300 ms reduction in JS execution Loss of early crash capture, delayed analytics
Removing unused native modules 50–150 ms reduction in native init None if truly unused

Key takeaways

  • Measure cold start time from tap to first interactive frame, not splash screen dismiss. Use PerformanceObserver or native profiling tools.
  • Enable inlineRequires only for modules that appear in the first rendered component tree; verify with a bundle report.
  • Preload the JS bundle into memory before calling AppRegistry.runApplication() and use a native splash as a placeholder.
  • Defer analytics, crash reporters, and push notification initialisation to after the first requestAnimationFrame callback.
  • Audit and remove unused native modules; consider lazy native modules for the rest. See Offline-First Sync with SQLite and REST in React Native for a related architecture pattern that benefits from a lean native init.

Frequently asked questions

How does inlineRequires actually reduce cold start time?
It marks React components and their dependencies for synchronous loading before the JS bundle executes. Placed at the root, it forces the bundler to inline critical modules, cutting the first render cycle by skipping extra I/O frames.
Which Hermes flags give the most cold start wins without refactoring?
The Hermes engine compiles JS ahead of time and includes compressed bytecode storage. The single biggest gain is enabling hermes.enableInlineRequires in metro.config.js and setting hermes.inlineSnapshots to true. After that, audit require() calls inside useEffect hooks that run on mount.
Can a third-party library like i18n or Sentry actually increase cold start time?
Yes. A large i18n or analytics library imported at the module level forces the entire dependency tree to parse before any screen renders. The fix is dynamic imports inside the component that first needs them, combined with a suspense fallback that shows a skeleton.
#react-native#performance#hermes#mobile#cold-start
Share

Keep reading