DEV Community

Cover image for Startup‑time budget for React Native apps using Hermes
Amit chakraborty
Amit chakraborty

Posted on Originally published at amitchakraborty.dev

Startup‑time budget for React Native apps using Hermes

The situation

Say you are a lead mobile engineer at a two‑year‑old fintech startup. The product ships daily to both iOS and Android, and the next release is due in two weeks. The product manager has just received a user‑experience survey that shows a 30 % drop‑off on the first screen for Android users who open the app from a cold state. The crash‑free rate is unchanged, but the analytics dashboard reports that the average cold‑start time has risen from 1.8 s to 2.4 s on the most common Android handset (Pixel 5, Android 13).

Your team has already enabled Hermes 0.13.1 in the Android Gradle file, but the metric is still above the target. The product manager has asked for “faster startup” and threatens to postpone the feature release unless you can prove that the app will launch under 1.5 s on the same device.

The cost of missing the deadline is concrete: a delayed feature rollout, a potential loss of $150 k in projected revenue, and a week of overtime for the mobile squad.


Why it happens

1. What Hermes actually does

Hermes is a JavaScript engine that compiles the bundle to byte‑code ahead‑of‑time (AOT) and stores it in a .hermes file. The engine eliminates the JIT warm‑up cost that the default JavaScriptCore (JSC) incurs on Android. According to the React Native 0.71 release notes, Hermes 0.13 reduces bundle size by ≈30 % and cold‑start time by ≈20 % on mid‑range Android devices compared with JSC.

2. The hidden latency sources

Even with Hermes, a cold launch still traverses several stages:

Stage Typical contribution (Android) Why it varies
Native process start 200‑300 ms Device CPU, OS scheduler
Loading the .hermes bundle from storage 150‑250 ms Storage speed, bundle size
Deserialising byte‑code 80‑120 ms Hermes version, device RAM
Running the JS entry point (AppRegistry.registerComponent) 300‑500 ms Number of modules, heavy initialisation
Bridge initialisation (if not using TurboModules) 100‑200 ms Number of native modules, native‑module overhead

If any of these steps exceeds its typical range, the total can easily breach 2 s. The “make it faster” request is therefore a request to shrink the envelope of these stages to fit under a performance budget.

3. The budget‑gap trap

A vague target (“make it faster”) invites endless optimisation attempts without a clear stop‑line. Teams may spend weeks refactoring code that only saves 10 ms, while the real blocker is the bundle size or native module count. Without a budget, there is no way to:

  • Prioritise the most‑impactful changes.
  • Communicate progress to non‑technical stakeholders.
  • Guard against regression after the next release.

The fix, step by step

Below is a reproducible workflow that turns “faster” into “≤ 1.5 s cold start on Pixel 5 (Android 13)” and gives you a defensible metric to show the product manager.

Step 1 – Capture a baseline with the exact tooling

  1. Install adb and perfetto (Android 13+).
   $ adb install -r path/to/app-debug.apk
   $ adb shell pm clear com.example.app   # ensure cold start
   $ perfetto -c - --out /data/misc/perfetto-traces/trace.pb > trace.pb
Enter fullscreen mode Exit fullscreen mode
  1. Run the app from a cold state and stop the trace after the splash screen disappears.
   $ adb shell am start -W -n com.example.app/.MainActivity
Enter fullscreen mode Exit fullscreen mode
  1. Analyse the trace with Android Studio’s Profiler → Trace Viewer.
    • Look for the “App start” event; note the totalTimeMs field.
    • Verify that the Hermes byte‑code load appears (label “Hermes bytecode load”).

What to confirm: The trace shows a total cold‑start time of ≈ 2 400 ms on the target device, matching the analytics dashboard.

Step 2 – Define a concrete performance budget

Metric Target Rationale
Total cold start ≤ 1 500 ms Keeps first‑screen under 2 s perceived latency
Bundle size (compressed) ≤ 5 MB Empirically reduces storage load time
Number of native modules ≤ 30 Limits bridge overhead
Hermes byte‑code load ≤ 200 ms Leaves headroom for JS execution

Write the budget into a performance-budget.json file at the repo root:

{
  "coldStartMs": 1500,
  "bundleSizeMb": 5,
  "nativeModules": 30,
  "hermesLoadMs": 200
}
Enter fullscreen mode Exit fullscreen mode

Step 3 – Automate measurement in CI

Create a Node script scripts/measurestart.js that:

  1. Pulls the latest trace from the device (adb pull /data/misc/perfetto-traces/trace.pb).
  2. Parses the protobuf with @perfetto/sdk to extract the required fields.
  3. Fails the CI job if any metric exceeds the budget.
// scripts/measurestart.js
const { PerfettoTrace } = require('@perfetto/sdk');
const fs = require('fs');
const budget = require('../performance-budget.json');

async function main() {
  const trace = await PerfettoTrace.fromFile('trace.pb');
  const appStart = trace.select('android.startup.app_start')
                         .first();

  const totalMs = appStart?.duration?.toNumber() ?? Infinity;
  const hermesLoad = trace.select('hermes.bytecode_load')
                          .first()?.duration?.toNumber() ?? Infinity;

  if (totalMs > budget.coldStartMs) {
    console.error(`Cold start ${totalMs} ms > ${budget.coldStartMs} ms`);
    process.exit(1);
  }
  if (hermesLoad > budget.hermesLoadMs) {
    console.error(`Hermes load ${hermesLoad} ms > ${budget.hermesLoadMs} ms`);
    process.exit(1);
  }
  console.log(`✅ Cold start ${totalMs} ms, Hermes load ${hermesLoad} ms`);
}
main().catch(err => { console.error(err); process.exit(1); });
Enter fullscreen mode Exit fullscreen mode

Add to GitHub Actions (or your CI system) a job that:

- name: Install app on Pixel 5 emulator
  run: |
    adb install -r app-debug.apk
    adb shell pm clear com.example.app
- name: Record perfetto trace
  run: |
    perfetto -c - --out /data/misc/perfetto-traces/trace.pb &
    TRACE_PID=$!
    adb shell am start -W -n com.example.app/.MainActivity
    kill $TRACE_PID
    adb pull /data/misc/perfetto-traces/trace.pb .
- name: Verify performance budget
  run: node scripts/measurestart.js
Enter fullscreen mode Exit fullscreen mode

What to confirm: CI now fails with a clear error message if the cold start exceeds 1 500 ms. The output “✅ Cold start 1 342 ms, Hermes load 173 ms” appears on successful runs.

Step 4 – Identify the biggest contributors

Run the baseline trace in Android Studio and zoom into the longest spans. Typical culprits:

  • Large JS bundle – check bundleSizeMb.
  • Heavy native module initialisation – count modules with react-native config and inspect android/app/src/main/java/... for static initialisers.
  • Synchronous asset loading – look for AssetManager.open calls on the UI thread.

Document the findings in a startup-analysis.md file.

Step 5 – Reduce the JS bundle size

  1. Enable Hermes byte‑code minification (already on by default).
  2. Activate RAM bundles (enableHermes: true, bundleInDebug: false, hermesFlags: ['-O', '--emit-binary']).
  3. Remove unused libraries – run npx depcheck and prune dead imports.
  4. Split the entry point – move rarely‑used screens into dynamic imports (import()), ensuring they are loaded after the first render.
// App.js
import {AppRegistry} from 'react-native';
import {name as appName} from './app.json';

// Lazy‑load heavy screen
const HeavyScreen = React.lazy(() => import('./HeavyScreen'));

AppRegistry.registerComponent(appName, () => () => (
  <React.Suspense fallback={<Splash/>}>
    <HeavyScreen />
  </React.Suspense>
));
Enter fullscreen mode Exit fullscreen mode

Verification: After each change, rebuild (./gradlew assembleDebug) and check the APK size (adb shell pm path com.example.app). The compressed .hermes file should be ≤ 5 MB. Run the CI job again; the total cold‑start time should drop proportionally (bundle size reductions of 10 % typically shave ≈ 30 ms off the load stage).

Step 6 – Trim native modules

  1. Run npx react-native config and count android/ native modules.
  2. For each module, ask:
    • Is it used on the first screen?
    • Can its initialisation be deferred (ReactPackage lazy loading) or moved to a background thread?

Example: defer react-native-fs initialisation.

// In MainApplication.java
@Override
public List<ReactPackage> getPackages() {
  List<ReactPackage> packages = new PackageList(this).getPackages();
  // Remove heavy package from eager list
  packages.removeIf(p -> p instanceof RNFSPackage);
  return packages;
}

// Later, when needed
ReactInstanceManager manager = getReactInstanceManager();
manager.addPackage(new RNFSPackage());
Enter fullscreen mode Exit fullscreen mode

Verification: After removal, the bridge initialisation span in the trace should shrink from ~180 ms to ~120 ms. CI will flag any regression if the native‑module count exceeds 30.

Step 7 – Optimise Hermes byte‑code load

Hermes can be tuned via hermesFlags in android/app/build.gradle:

project.ext.react = [
    enableHermes: true,
    hermesFlags: ['-O', '--emit-binary', '--max-inline-size=128']
]
Enter fullscreen mode Exit fullscreen mode
  • -O enables optimisations.
  • --max-inline-size reduces the size of inlined functions, lowering deserialisation cost.

Verification: Re‑build, run a trace, and confirm the “Hermes bytecode load” span is ≤ 200 ms. The CI script already checks this.

Step 8 – Validate on the target device under realistic conditions

  1. Cold‑boot the device (power off, power on).
  2. Disable background apps to avoid memory pressure.
  3. Run the CI script manually on the device to ensure the same numbers appear outside the emulator.

What to confirm: The final trace shows total cold start ≤ 1 450 ms (a safety margin of 50 ms). Document the exact numbers in a performance-report.md and share the file with the product manager.

Step 9 – Guard the budget in future releases

Add a pre‑merge hook (husky + lint-staged) that runs node scripts/measurestart.js against a baseline trace generated from the current HEAD. If a developer introduces a change that pushes the bundle over 5 MB, the commit is rejected.

// package.json
"husky": {
  "hooks": {
    "pre-push": "node scripts/measurestart.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

Result: Every PR now has a hard gate that prevents budget violations, making the “≤ 1.5 s” guarantee enforceable across the team.


What it costs, and when not to do it

Cost Description
Build‑time overhead Enabling Hermes and generating .hermes files adds ≈ 30 s to a full Android Gradle build (observed on a MacBook Pro M1, 16 GB RAM). This is acceptable for CI but may slow local iteration.
Debugging friction Hermes disables Chrome DevTools source‑maps by default; you must enable hermesDebug = true and use Flipper’s Hermes plugin. This adds a small learning curve.
Feature‑compatibility limits Some JS features (e.g., Intl polyfills) are not fully supported in Hermes 0.13. Work‑arounds require adding polyfills, increasing bundle size.
Device‑specific regressions On very low‑end Android 5 devices, Hermes can increase startup because the byte‑code deserialiser is CPU‑bound. In such cases, falling back to JSC may be cheaper.
Operational complexity Maintaining a performance‑budget JSON, CI trace collection, and pre‑push hooks adds ~2 person‑days of engineering effort and ongoing maintenance.

When not to apply the budget:

  • If the app targets only high‑end devices where cold start is already sub‑1 s.
  • When the product timeline does not allow the extra CI time (e.g., a hot‑fix that must ship within 24 h).
  • If the team lacks the expertise to interpret perfetto traces; a mis‑interpreted metric could lead to wasted optimisation effort.

In those cases, a single‑metric “cold‑start < 2 s” gate may be sufficient, and you can defer the full budget implementation to a later sprint.


At your level

Role Immediate take‑away One concrete next step
Student / career‑switcher Performance budgets turn vague goals into measurable limits. Clone a simple RN app, enable Hermes, record a perfetto trace, and compare the totalTimeMs before and after.
Working engineer Automate the budget in CI and protect it with a pre‑push hook. Add the scripts/measurestart.js script to your repo and make the CI job fail on budget breaches.
Senior / staff Budget enforcement must be coupled with regular bundle‑

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)