◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 23 days left. If your app calls it, check the migration target●SDK 58 — Moves to React Native 0.88. R8 is now on by default in Android release builds, and iOS prebuild generates a SceneDelegate●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 23 days left. If your app calls it, check the migration target●SDK 58 — Moves to React Native 0.88. R8 is now on by default in Android release builds, and iOS prebuild generates a SceneDelegate●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-08-09Advanced

Measuring Hermes Bytecode Delta Updates: One Added Module Cost 29x More Than One Changed Line

I measured OTA delta sizes against a 2.4MB Hermes bytecode bundle. Changing one line cost 2,104 bytes; adding one module cost 61,197. The widely repeated advice about stable module IDs turned out to matter least.

Hermes10EAS Update8OTA7Expo234React Native243

✦ Premium Article

There was a month where a copy tweak — one string, one line — shipped as a surprisingly heavy OTA update.

As an indie developer running several apps, I find EAS bandwidth adds up in a quiet way. A one-word change pulling tens of kilobytes felt wrong, and "felt wrong" is not something I enjoy leaving in that state.

So I built a small measurement rig instead of theorizing. Synthesize a bundle, compile it to Hermes bytecode, and line up the delta size for each kind of change.

Two of my predictions turned out to be wrong.

What I was actually trying to measure

The bandwidth cost of an OTA update comes down to one number: how many bytes separate the previous version from the new one.

Ship the whole JS bundle and you pay the gzipped size every time. Ship a delta and you should pay something proportional to how much actually changed. That expected proportionality is the whole appeal of Hermes bytecode diffing.

What I wanted to know was where the proportionality breaks.

My prediction going in: Metro numbers its modules sequentially, so adding one module shifts every ID after it. Those shifted IDs sit inside dependency arrays across the whole file, so the delta smears out and balloons. Fix it with a stable createModuleIdFactory and the problem goes away.

That prediction did not survive the measurement.

Building the rig

Comparing two real builds mixes changes together and makes attribution impossible. So I generated a synthetic bundle shaped like Metro output.

480 modules, each with 34 small functions and 28 dependency lookups, emitted as __d(function(g,r,i,a,m,e,d){...}, ID, [DEPS]);. That produced roughly 1.97MB of JavaScript, which Hermes compiled into about 2.40MB of bytecode — the same order of magnitude as the real bundles I ship.

# gen.py — run with python3 gen.py
# requires: pip install bsdiff4 --break-system-packages
import random, hashlib, gzip, json, bsdiff4
 
random.seed(20260809)
N = 480
NAMES = [f"src/features/{chr(97 + i % 26)}{i}/module{i}" for i in range(N)]
 
def body(i, tweak=False):
    """A module body with enough bulk to resemble real Metro output."""
    lines = ["'use strict';Object.defineProperty(e,'__esModule',{value:!0});"]
    for k in range(28):
        lines.append(f"var _v{k}=r(d[{k % 6}]).default;")
    # only this one string literal changes when tweak=True
    label = "saved to library" if tweak else "saved"
    lines.append(f"var LABEL_{i}='{label}';")
    for k in range(34):
        lines.append(
            f"function h{i}_{k}(a,b){{var s=a+{k}*{i};if(s>{k * 7 + 3})"
            f"{{return _v{k % 28}(s,'k{k}');}}return b?b(s):s;}}"
        )
    exports = ",".join(f"h{k}:h{i}_{k}" for k in range(34))
    lines.append(f"e.default={{LABEL:LABEL_{i},{exports}}};")
    return "".join(lines)
 
def build(ids, order, tweak_at=None, extra=None, extra_pos=None):
    """ids maps module index -> module ID (numeric or string)."""
    out = []
    for idx in order:
        deps = [ids[(idx + 1 + j) % N] for j in range(6)]
        out.append(
            f"__d(function(g,r,i,a,m,e,d){{{body(idx, tweak=(idx == tweak_at))}}},"
            f"{json.dumps(ids[idx])},{json.dumps(deps)});\n"
        )
    if extra is not None:
        out.insert(len(out) if extra_pos is None else extra_pos, extra)
    return ("var __d=function(){};\n" + "".join(out)).encode()
 
order = list(range(N))
num_ids = {i: i for i in order}
hash_ids = {i: hashlib.sha1(NAMES[i].encode()).hexdigest()[:8] for i in order}
 
NEW_BODY = 'function(g,r,i,a,m,e,d){"use strict";e.default=function(x){return x*2;};}'
new_num = f'__d({NEW_BODY},480,[]);\n'
new_hash = f'__d({NEW_BODY},{json.dumps(hashlib.sha1(b"src/features/new/doubler").hexdigest()[:8])},[]);\n'
 
variants = {
    "v1":  build(num_ids, order),                    # baseline, numeric IDs
    "v1c": build(hash_ids, order),                   # baseline, hashed IDs
    "vA":  build(num_ids, order, tweak_at=300),      # one string literal changed
    "vB":  build({i: i + 1 for i in order}, order),  # every ID shifted, no code change
    "vC":  build(num_ids, order, extra=new_num),     # one module appended
    "vD":  build(hash_ids, order, extra=new_hash),   # same append, hashed IDs
}
for name, data in variants.items():
    open(f"{name}.js", "wb").write(data)
    print(f"{name}.js  {len(data):,} B  gzip={len(gzip.compress(data, 9)):,} B")

For the bytecode step I used the real Hermes compiler. hermes-engine-cli ships a Linux hermesc binary, so none of this requires a Mac.

npm install hermes-engine-cli
H=./node_modules/hermes-engine-cli/linux64-bin/hermesc
chmod +x "$H"
for f in v1 v1c vA vB vC vD; do
  "$H" -emit-binary -O -out "$f.hbc" "$f.js"
done
ls -l *.hbc

I ran two different differs: bsdiff4, which subtracts bytes before compressing, and zstd --patch-from, which is LZ-based. Drawing conclusions from a single differ means mistaking one implementation's quirks for a law.

# measure.py
import bsdiff4, subprocess, os
 
def bs(a, b):
    return len(bsdiff4.diff(open(a, "rb").read(), open(b, "rb").read()))
 
def zs(a, b):
    subprocess.run(["zstd", "-19", "--long=27", f"--patch-from={a}", b,
                    "-o", "/tmp/p.zst", "-f", "-q"], check=True)
    return os.path.getsize("/tmp/p.zst")
 
cases = [
    ("one string literal changed", "v1", "vA"),
    ("all module IDs shifted",     "v1", "vB"),
    ("one module appended",        "v1", "vC"),
    ("one module appended (hash)", "v1c", "vD"),
]
for label, a, b in cases:
    print(f"{label:30s} JS_bsdiff={bs(a+'.js', b+'.js'):>7,}  "
          f"HBC_bsdiff={bs(a+'.hbc', b+'.hbc'):>7,}  "
          f"HBC_zstd={zs(a+'.hbc', b+'.hbc'):>7,}")
✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦A measured table comparing bsdiff and zstd delta sizes across four kinds of change to the same bundle
✦Why stabilizing module IDs — the standard advice — barely moved delta size, and what the numbers say instead
✦The bundle split that took a delta from 61,197 bytes down to 1,247, with runnable measurement scripts
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ Dev Tools2026-10-25
Sound Effects Stop at Play #40 — With expo-audio's createAudioPlayer, remove() Alone Isn't Cleanup
Creating a createAudioPlayer instance per play in expo-audio can leave Android silent after about 40 plays, with didJustFinish never arriving. What remove() and release() each do, three ways to restructure, and how to notice when it happens.
⬡ Dev Tools2026-10-13
Before You Ship: Go Looking for Your Own Keys Inside the Exported Expo Bundle
A key can be missing from your source and still be sitting inside the finished app. I ran expo export on Expo SDK 57, searched the bundle with grep and a short Node script, and recorded which secrets show up and which don't.
⬡ Dev Tools2026-09-10
I Added a Dim Screen Button, and iPhones Stayed Dark After the App Was Closed
In expo-brightness, setBrightnessAsync applies only to the current activity on Android, but changes the device brightness itself on iOS. Here is why the cleanup burden falls on one platform only, and a hook that restores brightness through AppState.