DEV Community

Sunny Luo
Sunny Luo

Posted on AI-assisted

Why Hermes bytecode defeats binary diffing — and how we got a one-line OTA hotfix down to 3.4 KB

Disclosure: I maintain react-native-update, the open-source SDK behind Cresc, an OTA update service for React Native. The benchmark code and data are public, and the technique applies to anyone shipping Hermes bundles.

If you ship React Native over-the-air updates, you've probably assumed binary diffing solves the bandwidth problem: compute a bsdiff between the old and new bundle, send the patch, done. With plain JS bundles that's mostly true. With Hermes bytecode it isn't, and the reason is interesting.

We measured on real release bundles of a React Native 0.86 app (Hermes HBC v98, with common dependencies like Paper, SVG, Sentry and CameraKit; ~1.5 MB gzipped):

Release scenario Full bundle (gzip) Classic bsdiff Hermes-aware diff
One-line text change 1,544.6 KB 93.7 KB 3.4 KB (28× smaller)
Small feature (+1 component, ~60 LOC) 1,555.8 KB 411.6 KB 50.2 KB (8.2× smaller)
Medium feature (+2 screens, ~300 LOC) 1,604.7 KB 551.6 KB 97.8 KB (5.6× smaller)

A one-line change producing a 94 KB bsdiff patch is the giveaway: something is amplifying tiny source edits into large binary changes.

Problem 1: the string table is re-sorted on every compile

Hermes stores strings in a table and refers to them by ID throughout the bytecode. On every compile it sorts that table by identifier frequency, so a one-line change perturbs the frequencies and renumbers dozens or hundreds of string IDs. Every instruction that references a shifted ID changes too.

Result: two builds that differ by one line of JS end up differing in bytes all over the file. A generic diff algorithm sees thousands of scattered edits.

Fix: Hermes has a delta mode (hermesc -base-bytecode=<base>). Compiling every release against the root bytecode of its version chain pins existing string IDs in place, so only code that actually changed shows up in the diff.

A free 21%: move debug info out

Hermes embeds debug info in the bytecode by default. Compiling with -output-source-map moves it into an external .map, which shrinks the bytecode by about 21% before any diffing happens. You keep the map for symbolicating crashes; it just doesn't ship in every patch.

Problem 2: offset tables amplify every insertion

Hermes bytecode is full of tables that store absolute offsets: function headers, string storage, and so on. Insert a few bytes near the start and every offset after that point changes. Again, a tiny edit becomes a wide diff.

Fix: before diffing, apply a reversible transform that delta-encodes the offset fields (wrapping subtraction), so a global shift collapses into a single-point change. The client applies the patch and then runs the inverse transform to get the exact target bytecode. On top of the first two layers, this cuts patches by a further 9–36%.

The three layers are independent and their gains stack. They matter most for small changes, which is exactly the hotfix case OTA exists for.

Making it safe

Clever transforms are only worth it if they never brick an app:

  • Bit-exact round trip: every patch is checked so that inverseTransform(patch(transform(old))) == new, byte for byte.
  • Instruction-level equivalence: delta-mode builds are disassembled and compared with plain builds instruction by instruction. On any mismatch the delta build is discarded and the plain build ships. (Benchmarking this turned up a verifier bug that wrongly flagged UIntSwitchImm jump tables and silently threw away valid delta builds. That's fixed now.)
  • No Hermes-specific client code: the table layout descriptors ship inside the patch metadata, so the client's patcher is version-agnostic, and supporting a new Hermes version is a CLI-side config change.

Reproduce it

The benchmark harness and raw data are here: https://github.com/sunnylqm/hbc-diff-benchmark. I'd love to see numbers from other apps, especially large ones. If your results differ, open an issue.

Using it

The diffing is built into react-native-update (open source, around since 2016) and the hosted service Cresc. There's a free tier, and teams moving off CodePush can use the migration checklist.

Questions about Hermes internals or OTA pipelines are welcome in the comments.

Top comments (0)