The optimization episode: reducing startup overhead with bytecode prepackaging at build time, using the -O optimizer and -output-source-map flags for smaller and faster bytecode, and analyzing how bundle size affects cold start on real devices.

In episode 4 we learned that the Hermes build artifact is .hbc bytecode. Now the question is: how do we make that bytecode as fast and small as possible, so cold start feels instant?
Episode 5 covers three things: bytecode prepackaging, the -O optimizer, and the impact of bundle size on startup. This is a favorite episode for anyone who cares about metrics.
When a user opens the app, the sequence is: load the APK, initialize the runtime, load the bundle, and execute the first code. In a JIT engine, there are two extra jobs: parsing JavaScript and warming up the JIT. Hermes eliminates both by shipping bytecode that's already complete.
But bytecode isn't automatically optimal. File size affects load time from disk, and object allocation during first execution affects user perception. Optimizing at build time is an investment that pays off on every launch.
Prepackaging means all compilation work is moved to build time, so the device does nothing but execute. In React Native this already happens automatically: the bundle is compiled by hermesc before being embedded in the APK. We can confirm it by checking that the embedded asset is in bytecode format:
cd android
./gradlew assembleRelease
unzip -l app/build/outputs/apk/release/app-release.apk \
| grep -i bundleIf the lines shown end in .hbc, prepackaging is working correctly. The positive consequences: no parsing on device, no JIT warmup, and the bundle can be signed — memory and CPU are used only for pure execution.
Info
When --dev true, React Native skips prepackaging and loads the JS bundle directly from Metro — startup will be slower and memory usage larger. Legitimate performance measurements always use a release build.
Besides prepackaging, Hermes applies lazy compilation (we touched on this in episode 2). Functions that haven't been called yet aren't compiled in advance, so memory allocation during startup is lower. The effect is noticeable in apps with many modules — only the code that actually runs weighs on initial memory.
This doesn't mean writing large functions is bad; rather, make sure the code that executes first is small. Move heavy modules so they load when needed, for example with dynamic imports. This is a bundle-splitting strategy commonly used together with Hermes.
Hermes provides a bytecode optimizer enabled with the -O flag. The optimizer performs analysis at the bytecode level: removing dead code, merging redundant instructions, and reducing unused registers. An example of a full invocation:
build/bin/hermesc \
-O \
-emit-binary \
-bundle \
-output-source-map \
index.js \
-out index.hbcCompare the output size with and without -O:
build/bin/hermesc -emit-binary -bundle index.js -out index-noopt.hbc
build/bin/hermesc -O -emit-binary -bundle index.js -out index-opt.hbc
ls -lh index-noopt.hbc index-opt.hbcThe file with -O is almost always smaller — and a smaller size means faster loading from disk. On large bundles, the difference can be tens of kilobytes or more.
The -output-source-map flag produces a source map together with the bytecode, so stack traces can still be mapped back to the original source even after the code is optimized. Although it doesn't directly speed up startup, the source map is a prerequisite for measuring and fixing performance in production — without it you're blind to where errors happen.
ls -lh index.hbc index.hbc.mapThe index.hbc and index.hbc.map pair is stored as a build artifact. In episode 10 we'll use them to dissect production stack traces.
Bundle size is directly related to load time. The larger the file, the longer the device takes to read it from disk. The relationship is almost linear: a 15 MB bundle loads twice as slowly as a 7.5 MB one. Hermes helps in two ways:
But don't rely on Hermes alone. Also control the sources of size:
A quick way to measure the real impact: measure the time from tapping the icon until the first screen renders. On Android use ADB:
adb shell am start -W -n com.belajarhermes/.MainActivityThe TotalTime line in the output is the time from launch until the activity finishes — a number you can use as a baseline before and after optimization.
Before we close, get into the habit of comparing several build variants in one pipeline. Here's a simple script example for benchmarking size:
for mode in "noopt" "opt"; do
if [ "$mode" = "opt" ]; then
build/bin/hermesc -O -emit-binary -bundle index.js -out index-$mode.hbc
else
build/bin/hermesc -emit-binary -bundle index.js -out index-$mode.hbc
fi
stat -c "%n %s bytes" index-$mode.hbc
doneRecord these numbers on every release. If a new dependency drastically increases the bytecode size, you can reject it before it reaches production — not after.
Hermes startup is now under your control. Here's what you must take away:
-O flag enables the bytecode optimizer that shrinks and speeds up output.-output-source-map produces a source map as the bytecode artifact's companion.Next, in episode 6 we dive into the memory topic: how the Hades garbage collector works, generational and compacting GC, plus practices for minimizing memory churn in React Native apps. See you there!