Peakiq Blog
Optimize React Native Cold Start Time Under 1 Second
Learn how to reduce React Native cold start times from seconds to under one second using Hermes, deferred initialization, bundle size reduction, and lazy screen
A React Native app's cold start time is one of the few metrics where the cost of neglect compounds quietly until it doesn't. Slow launches don't usually show up as a single dramatic bug report — they show up as a slow erosion in retention, a string of one-star reviews using words like "laggy" or "slow to open," and a growing sense that something is wrong without anyone pointing at the exact cause.
That was the situation with an app we worked on recently. Cold start was sitting above four seconds. Users were backing out before the home screen even rendered. The fixes that brought it under a second weren't exotic — they were items that had been sitting in the backlog for months under the label "optimize later." Below is the breakdown of what we changed, in the order it mattered.
Step 1: Confirm Hermes Is Actually Doing Its Job
If there is one item to check before anything else, it's this. Hermes is Meta's JavaScript engine purpose-built for React Native, and its core advantage over JavaScriptCore or V8 is that it precompiles JavaScript into bytecode ahead of time. Instead of the device parsing and compiling your JS bundle at launch, it just executes already-compiled bytecode.
In recent React Native versions, Hermes is the default engine, so the common failure mode isn't "Hermes is off" — it's a leftover override that disabled it during a migration, or a build configuration that never got updated. Worth confirming directly in your Gradle config:
// android/app/build.gradle
project.ext.react = [
enableHermes: true
]And on iOS, in your Podfile:
:hermes_enabled => trueThis single configuration check accounted for the largest single chunk of recovered launch time in our case. If your app predates Hermes being default, or went through a bare-workflow migration at some point, it's worth verifying explicitly rather than assuming.
Step 2: Stop Doing Setup Work Before the First Paint
The next biggest offender was App.js itself. It's common for an app's root component to run a list of setup tasks synchronously before anything renders — initializing an SDK, setting up analytics, hydrating a Redux store from storage, resolving a deep link, checking a remote config flag. Each one feels small in isolation. Stacked together, they delay the very first frame the user sees.
The fix is to separate what must happen before render from what can happen after it. Almost nothing actually needs to block the first paint:
useEffect(() => {
// Runs after the first frame has already painted
const id = setTimeout(() => {
initAnalyticsSdk();
hydrateStore();
resolveDeepLink();
fetchRemoteConfig();
}, 0);
return () => clearTimeout(id);
}, []);setTimeout(fn, 0) pushes execution to the next tick of the event loop, which means the UI thread gets to paint the first screen before any of this work runs. The user sees something immediately; the app catches up quietly in the background. This single change is disproportionately effective relative to how little code it requires.
Step 3: Trim the JS Bundle
A larger bundle means more bytes to download, parse, and execute before your app can do anything. Three changes contributed the most here, with no architectural rework required.
Import only what you use. A full lodash import pulls in the entire library even if you're using two functions from it:
// Pulls in the entire library
import _ from 'lodash';
// Only pulls in what's actually used
import debounce from 'lodash-es/debounce';
import merge from 'lodash-es/merge';Strip console statements from production builds. Debug logging is useful in development and pure overhead in production:
// babel.config.js
module.exports = {
plugins: ['transform-remove-console'],
};Convert raster images to WebP. Hero images and other large raster assets are routinely 25–35% smaller in WebP than the equivalent PNG or JPEG with no visible quality loss, and a lighter bundle means less to process at startup.
None of these are individually dramatic, but together they meaningfully reduce what the JS engine has to load before your app is interactive.
Step 4: Load Screens On Demand, Not All at Once
The last major fix addressed how screens were imported. It's common, especially as an app grows, for every screen in the navigation tree to be imported eagerly at the top level — meaning the JS engine parses and initializes all of them on every single cold start, even the ones a given user session will never visit.
Switching to lazy loading for everything except the initial screen changes that:
import React, { Suspense } from 'react';
// Loaded immediately — this is what the user sees first
import HomeScreen from './screens/HomeScreen';
// Loaded only when actually navigated to
const ProfileScreen = React.lazy(() => import('./screens/ProfileScreen'));
const SettingsScreen = React.lazy(() => import('./screens/SettingsScreen'));
function AppNavigator() {
return (
<Suspense fallback={<LoadingSpinner />}>
{/* navigator and routes go here */}
</Suspense>
);
}Only the screen the user actually sees first pays the loading cost upfront. Everything else is deferred until it's actually needed, which has a direct and measurable effect on initial JS execution time.
The Real Lesson Here
None of these four fixes required new architecture, a major dependency upgrade, or weeks of work. Each one had been sitting in a backlog, individually small enough to deprioritize, collectively responsible for the bulk of a multi-second delay.
That's the pattern worth internalizing: on mobile, there is no loading spinner users are conditioned to wait through the way they sometimes will on the web. A slow cold start is just a black screen, and a black screen reads as broken. Perceived speed at launch is part of how users judge the rest of the product before they've used a single feature.
If your React Native app's cold start time is sitting above two seconds, this is a reasonable order to work through:
- Confirm Hermes is actually enabled and being used at build time.
- Move every non-essential setup task in your root component out from in front of the first paint.
- Audit your bundle for unnecessary imports, debug logging, and unoptimized images.
- Lazy-load every screen except the one the user lands on first.
None of it is clever. Most of it is just unfinished housekeeping — which is usually where the easiest wins are hiding.