Profiling React Native startup: Hermes, TTI, and chasing the cold start
· 3 min read · Amrith Vengalath
- React Native
- Performance
- Hermes
The first thing a user experiences is how long they stare at a splash screen. Cold start - app fully closed to interactive - is the most visible performance number you have, and unlike a lot of "performance work," it's concrete and measurable. You can put a number on it before and after, which makes it satisfying to fix.
Here's how I approach it, and where the wins actually are.
Measure first, and measure the right thing
The number that matters is time to interactive (TTI): app launch to the user being able to actually do something, not just pixels on screen. A splash screen appearing fast is meaningless if the app then sits frozen for two seconds.
You can mark this yourself - a timestamp at native launch, another when your first real screen is interactive, and the difference logged to your analytics. Do this on real devices, especially a mid-range Android phone, not a flagship and not the simulator. The simulator runs on your fast laptop and lies to you about startup. Your users are on three-year-old midrange Androids.
Hermes is the biggest free win
If you're not on Hermes, that's the first move. Hermes is the JavaScript engine built for React Native, and its whole point is startup: it precompiles your JS to bytecode at build time, so the app skips parsing and compiling JavaScript on the device at launch. For a reasonably large app that's a real chunk of cold-start time, and it's been the default for a while now - but plenty of older apps still haven't flipped it on.
Turning it on is a config flag and a rebuild. The bytecode precompilation means there's simply less work to do when the app boots. It's about as close to free performance as you get.
What the profiler shows you
Once Hermes is on, profile to find what's left. The thing that consistently surprises people: a lot of cold-start time is your own code running too eagerly at launch.
Common offenders I've found:
- Everything initializing on boot. Analytics SDKs, crash reporters, feature-flag clients, push setup - all firing synchronously during startup because that's where the
initcalls landed. Half of them don't need to run before the first screen. Defer what you can to after the app is interactive. - Importing the world at the top. A module imported at app entry that drags in a huge dependency tree gets evaluated at startup whether or not you need it yet. Lazy-loading heavy screens and libraries keeps them out of the boot path.
- Big synchronous work on the JS thread before first render - reading and parsing a large cache, building derived data structures. Move it after first paint or off the main path.
// instead of initializing everything synchronously at module load:
export async function initNonCritical() {
// runs AFTER the first screen is interactive
await Promise.all([analytics.init(), remoteConfig.fetch()]);
}
// call initNonCritical() once the app has rendered, not during importBundle and assets
Smaller JS bundle, faster load. Enabling the Hermes bytecode path helps here too, and inline requires / RAM bundles can defer loading parts of the JS until first used. Oversized images bundled into the app also cost - shipping a 2000px asset to display at 100px is load time you're paying on every launch for no reason.
The result, and the honest caveat
On the work where I chased this hardest, the combination - Hermes on, deferring non-critical init, lazy-loading heavy screens, trimming startup work - took a sluggish cold start down to something that felt immediate on the same cheap test device. The single biggest lever was Hermes; the second was simply not doing so much work before the first screen.
The honest caveat: measure on real devices and trust the numbers, not the vibe. It's easy to "optimize" something that wasn't the bottleneck and feel productive while the actual TTI didn't move. Mark TTI, change one thing, look at the number. Boring, but it's the only way to know you actually helped.