The situation
You are a senior mobile engineer on a React‑Native team that just upgraded the JavaScript engine from JSC to Hermes (React‑Native 0.71). The product manager has noticed a 15 % increase in user‑reported “app takes too long to open” complaints after the release. The crash‑free users metric fell from 99.6 % to 98.9 % over a week, and the support team is logging roughly 30 extra tickets per day. You need to know whether the Hermes migration is the cause, and if so, whether a code rewrite can recover the lost start‑up time before you spend weeks refactoring.
Why it happens
Hermes improves start‑up by pre‑compiling JavaScript to bytecode and by reducing the memory footprint of the runtime. However, the benefits only materialise when the bundle is small enough to be parsed quickly and when native modules do not block the main thread during initialisation. Common regressions after a Hermes switch are:
| Symptom | Typical cause | Why it hurts start‑up |
|---|---|---|
| Cold‑start time ↑ 200‑400 ms | Large bundle (> 8 MB) | Bytecode loader still has to read the whole file from storage; I/O dominates. |
| First‑frame delay ↑ | Native modules performing heavy work on the UI thread in initialize()
|
The JS thread cannot hand over control to the UI thread until the native side finishes. |
| Increased binary size (+ 2‑3 MB) | Hermes runtime added to the APK/AAB | Larger download may delay installation, but not runtime start‑up; however, it can affect device‑level caching. |
The key point is that Hermes removes the JIT compilation cost but does not magically fix I/O‑bound or native‑blocking work. Without precise measurements you cannot know which part of the start‑up path is now the bottleneck.
The fix, step by step
Below is a reproducible workflow that gives you quantifiable data on a real device (or emulator) and tells you whether a rewrite is justified. The steps assume you have Android SDK 30+, adb on PATH, and a React‑Native 0.71 project built with Hermes enabled (hermesEnabled: true in android/app/build.gradle).
1. Verify the build is using Hermes
cd android
./gradlew app:assembleRelease -PhermesEnabled=true
Why: Guarantees the binary you will test contains the Hermes runtime.
Confirm: In the generated app-release.apk, open lib/arm64-v8a/libhermes.so. Presence of the file means Hermes is bundled.
2. Install the APK on a clean device
adb uninstall com.example.myapp || true
adb install -r android/app/build/outputs/apk/release/app-release.apk
Why: Removes any cached JSC artefacts that could skew timings.
Confirm: adb shell pm list packages | grep com.example.myapp should list the package.
3. Warm‑up the device cache (optional but recommended)
adb shell "pm clear com.example.myapp"
adb shell "am start -W -n com.example.myapp/.MainActivity"
Why: Clears any previous launch state so you can measure a true cold start.
Confirm: The am command returns a line like TotalTime: 2123 ms; note it, you will compare later.
4. Capture baseline cold‑start metrics with adb
adb shell "logcat -c" # clear previous logs
adb shell "am start -W -n com.example.myapp/.MainActivity" > start.txt
adb logcat -d | grep -i "reactnative" > rn.log
Why: am start -W reports three numbers you need:
- TotalTime – wall‑clock time from activity start to first draw.
- WaitTime – time spent waiting for the process to start.
-
LaunchTime – time the activity spent executing its
onCreatebefore drawing.
Confirm: cat start.txt should contain lines such as:
Status: ok
Activity: com.example.myapp/.MainActivity
ThisTime: 0
TotalTime: 2150
WaitTime: 150
LaunchTime: 2000
Record TotalTime as your cold‑start baseline.
5. Enable Hermes‑specific performance logging
Add the following to android/app/src/main/java/com/example/myapp/MainApplication.java inside onCreate():
if (BuildConfig.DEBUG) {
System.setProperty("hermes.debug", "true");
}
Re‑build and reinstall the debug variant:
cd android
./gradlew assembleDebug
adb uninstall com.example.myapp || true
adb install -r app/build/outputs/apk/debug/app-debug.apk
Why: Hermes will now emit hermes:profile events to logcat that contain bytecode load time.
6. Collect Hermes profile logs
adb logcat -c
adb shell "am start -W -n com.example.myapp/.MainActivity"
adb logcat -d | grep "hermes:profile" > hermes_profile.log
Typical line:
I/hermes:profile: LoadBytecode: 78ms InitRuntime: 12ms ExecuteMain: 34ms
Why: Breaks down the JS side of start‑up.
Confirm: Sum of the three numbers should be ≤ 150 ms for a well‑optimised bundle. If you see > 300 ms, the bundle size or bytecode generation is a problem.
7. Measure native‑module blocking
Instrument the native side with android.os.Trace in each heavy initialize() method, for example in MyNativeModule.java:
public void initialize() {
Trace.beginSection("MyNativeModule.initialize");
// existing heavy work
Trace.endSection();
}
Re‑build and reinstall, then run a trace:
adb shell "atrace --async_start -b 4096 -c -t 10 -o /data/local/tmp/trace.txt -app com.example.myapp"
adb shell "am start -W -n com.example.myapp/.MainActivity"
adb shell "atrace --async_stop -o /data/local/tmp/trace.txt"
adb pull /data/local/tmp/trace.txt .
Open trace.txt in Android Studio’s CPU Profiler or use perfetto to locate sections named MyNativeModule.initialize.
Why: Gives you the exact time native code spent on the UI thread before JS could start.
Confirm: If any section exceeds 100 ms, that module is a candidate for async initialisation.
8. Compare against a JSC baseline (optional)
If you still have the previous JSC build, repeat steps 3‑7 with hermesEnabled=false. Record the same three metrics.
Why: Provides a concrete delta to decide whether Hermes is helping or hurting.
Confirm: A table like:
| Build | TotalTime (ms) | Bytecode load (ms) | Native init max (ms) |
|---|---|---|---|
| JSC | 2100 | N/A | 45 |
| Hermes | 2250 | 120 | 90 |
If Hermes is slower, the extra bytecode load plus native blocking outweighs JSC’s JIT cost.
9. Decide on a rewrite
-
If
TotalTimeis > 200 ms higher than the JSC baseline and the Hermes bytecode load exceeds 100 ms or any native init > 80 ms, a rewrite (splitting bundles, lazy‑loading heavy modules, moving work off the UI thread) is justified. - If the delta is < 50 ms and the heavy native sections are < 30 ms, the regression is likely noise; focus on other performance goals (e.g., reducing image decoding time).
Document the numbers in a short report and present them to product and engineering leads before committing to a large refactor.
What it costs, and when not to do it
| Cost | Description |
|---|---|
| Developer time | Setting up tracing and Hermes logging takes ~2 h per platform. |
| Build churn | Requires at least two full builds (Hermes and JSC) plus a debug rebuild for tracing. |
| Device time | Each measurement run needs a clean device; on a CI farm you must provision a physical Android device or a high‑performance emulator (cold‑start times differ by up to 30 %). |
| Binary size | Enabling Hermes adds ~2.5 MB to the APK; if the app is already near the 100 MB Play Store limit, you may need to enable Android App Bundles with dynamic delivery. |
| Debugging complexity | Hermes disables Chrome DevTools source‑maps unless hermes.debug is true, which can hide bugs in production. |
When not to do it
- If your app’s average cold start is already < 1 s and the user‑complaint rate is < 0.2 % per week, the cost of a rewrite may outweigh the benefit.
- If the only regression is a 20‑30 ms increase in
TotalTimeon high‑end devices, the impact on real‑world user experience is negligible. - When your team does not have bandwidth to refactor native modules to async initialisation, the risk of introducing regressions may be higher than the gain.
At your level
| Role | Immediate takeaway | Next concrete step |
|---|---|---|
| Student / first‑year engineer | Measuring is cheaper than guessing. | Run adb shell am start -W on a sample app and note the three timing fields. |
| Working engineer (feature owner) | Use Hermes‑specific logs to separate JS load from native init. | Add System.setProperty("hermes.debug","true") and capture hermes:profile lines for your feature branch. |
| Senior / staff engineer | Quantify the trade‑off between bytecode load and native blocking before approving a full rewrite. | Build a side‑by‑side JSC vs Hermes comparison table for the next release sprint. |
| Lead / director | The decision to allocate refactor time must be backed by a ≥ 100 ms total‑time delta on the target device cohort. | Request a performance‑measurement ticket that includes the steps above and a budget justification. |
In the interview
Question phrasing
“We migrated a React‑Native app to Hermes and saw a slowdown in cold‑start time. How would you determine whether the migration is the cause and if a code rewrite is justified?”
Weak answer
- Mentions “Hermes is faster” without data.
- Says “just look at the logs” without specifying which logs or how to isolate the problem.
- Fails to mention native‑module blocking or bundle size.
Why it is weak: It shows awareness of Hermes but no concrete methodology, no metrics, and no trade‑off analysis.
Strong answer
- Describes the three‑phase measurement (
am start -W,hermes:profile,atrace). - Cites concrete thresholds (e.g., > 100 ms bytecode load, > 80 ms native init).
- Explains how to compare against a JSC baseline and how to decide based on a ≥ 100 ms delta.
- Mentions the cost of the measurement effort and the binary‑size impact.
Senior vs junior expectations
-
Junior: Expected to outline the basic
adbcommand, recognise the need for a baseline, and suggest looking athermes:profile. - Senior / staff: Must also discuss native‑module tracing, explain why bundle size matters, propose a data‑driven decision matrix, and address the engineering cost.
- Director / engineering‑manager: Looks for the business impact (user‑complaint rate, revenue loss), the ROI of a rewrite, and how the measurement plan fits into release governance.
Follow‑up that separates experience from theory
“When you performed the comparison, what was the largest native‑module init time you observed, and how did you refactor it?”
A candidate who can cite a concrete figure (e.g., “MyAnalytics.initialize took 120 ms and was moved to a background thread using ReactContext.runOnJSQueueThread”) demonstrates hands‑on experience; a candidate who answers only “we moved it off the UI thread” without numbers likely has only read the docs.
Amit Chakraborty is a founding engineer and senior architect — React Native, AI/RAG systems and production architecture. Portfolio: www.amitchakraborty.dev · LinkedIn · GitHub. Open to senior and founding engineering roles, remote worldwide.
Top comments (0)