●SDK58BETA — Expo SDK 58 is still in beta. The only thing stated officially is "three to four weeks", and no stable date appears in any primary source, so it is safer not to plan around one●RN0.88RC1 — React Native 0.88 reached rc.1 on September 16, with the stable release expected October 12. The type-level changes arriving in SDK 58 are the other half of that story●11/01 — For anyone who filed a Google Play target API level extension, the delivery deadline is November 1, thirty-nine days away. The extension can be requested once, from Policy status in Play Console●AUDIO — expo-audio is reported to stop after a certain number of playbacks, with status.didJustFinish never arriving. Nothing throws; the next sound simply never starts, which makes it easy to miss●NEW — Designing the branch after one Checkout carries four different products●LSAQS — lsapplicationqueriesschemes shows up 22 times in search with zero clicks. The largest implementation-side demand this site sees still has no answer anywhere on it●SDK58BETA — Expo SDK 58 is still in beta. The only thing stated officially is "three to four weeks", and no stable date appears in any primary source, so it is safer not to plan around one●RN0.88RC1 — React Native 0.88 reached rc.1 on September 16, with the stable release expected October 12. The type-level changes arriving in SDK 58 are the other half of that story●11/01 — For anyone who filed a Google Play target API level extension, the delivery deadline is November 1, thirty-nine days away. The extension can be requested once, from Policy status in Play Console●AUDIO — expo-audio is reported to stop after a certain number of playbacks, with status.didJustFinish never arriving. Nothing throws; the next sound simply never starts, which makes it easy to miss●NEW — Designing the branch after one Checkout carries four different products●LSAQS — lsapplicationqueriesschemes shows up 22 times in search with zero clicks. The largest implementation-side demand this site sees still has no answer anywhere on it
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.
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-packagesimport random, hashlib, gzip, json, bsdiff4random.seed(20260809)N = 480NAMES = [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-cliH=./node_modules/hermes-engine-cli/linux64-bin/hermescchmod +x "$H"for f in v1 v1c vA vB vC vD; do "$H" -emit-binary -O -out "$f.hbc" "$f.js"donels -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.
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.
The baseline bundle came out at 1,965,412 bytes of JavaScript (151,037 gzipped) and 2,403,772 bytes of bytecode (374,232 gzipped). Ship it whole and even a one-character fix costs you 151KB or 374KB.
Here is what the deltas looked like.
Change
JS text / bsdiff
Bytecode / bsdiff
Bytecode / zstd
One string literal changed
191 B
2,104 B
67,592 B
All module IDs shifted
456 B
589 B
32,696 B
One module appended (numeric IDs)
264 B
61,197 B
80,064 B
One module appended (hashed IDs)
—
5,617 B
109,846 B
I stared at that table for a while. The row I had been worried about produced the smallest number on the board.
"Metro IDs are sequential, so adding a module shifts everything after it — switch to path-derived stable IDs with createModuleIdFactory" is advice you see everywhere. I believed it.
But shifting every module ID while changing no code at all (row two) produced a 589-byte bytecode delta. That is smaller than changing a single string literal.
The reason is in how bsdiff works. It can express more than "copy this matching run" — it can also say "take the old bytes and add this offset." When 301 becomes 302 and 302 becomes 303 across the file, the difference values form a long, uniform run. Uniform runs compress away to almost nothing.
In other words, a uniform ID shift is exactly the shape of change a byte-subtracting differ handles best. Fixing it moves your payload almost not at all.
I had a metro.config.js change scheduled to implement stable IDs. Without measuring, I would have spent half a day on something worth 589 bytes.
Wrong prediction #2: the choice of differ was decisive for bytecode
The second surprise was that the same change landed an order of magnitude apart depending on the differ.
One changed string: 2,104 bytes with bsdiff, 67,592 bytes with zstd --patch-from. Roughly 32x apart.
On the JS text, the same change measured 191 bytes with bsdiff and 202 bytes with zstd. Effectively identical.
The gap exists because bytecode embeds offsets inside itself. Add one function and every table entry pointing past it shifts, scattering thousands of slightly-changed integers across the file. An LZ-based differ can only express "copy this matching run," so it cannot absorb thousands of values that each moved by one. A byte-subtracting differ folds them into near-zero difference runs.
If you ship JS text, the choice of differ hardly matters. If you ship bytecode, it is the single biggest fork in the road. Build a delta pipeline on the wrong side of that fork and most of the benefit of diffing disappears.
The real culprit was adding a module at all
That leaves row three. Appending one module — at the end, changing nothing else — produced a 61,197-byte delta. About 29x the cost of changing a string literal.
The same change on the JS text costs 264 bytes. Compiling to bytecode turns "add a module," one of the most routine things you do, into an unusually expensive operation in bandwidth terms.
My first pass concluded here that position made no difference. A later re-run retired that sentence, and I have kept the correction in the next section. Row two does still rule out ID shifting as the cause.
What remains is the function table. A Hermes bytecode file carries a header entry for every function; this bundle contained 16,802 of them. Adding one module adds two functions, which lengthens that header region and pushes the entire bytecode body downstream. All 16,802 header offsets get rewritten. That pile of tiny rewrites is the 61KB.
The hashed-ID row drops to 5,617 bytes, but zstd reverses the ordering (109,846 bytes). When two differs disagree on direction there is no reproducible effect to act on, so I am leaving that row in as a curiosity rather than a recommendation. What a measurement fails to establish is worth reporting as plainly as what it does.
Re-running it on a different build moved a whole column
After writing the above, I ran the same rig again in a different environment — the Linux hermesc (LLVH 8.0.0svn), with the generator untouched down to the character. Numbers only settle once you have tried to knock over your own table.
The baseline held. Bytecode size landed on 2,403,772 bytes exactly, the ID-shift-only delta measured 581 bytes against 589 the first time, and the one-line string change measured 2,063 against 2,104. Those two rows did not care which build of the compiler produced them.
Row three did.
Change
Re-run (bsdiff)
First run
One string literal changed
2,063 B
2,104 B
All module IDs shifted
581 B
589 B
One module appended at the end (numeric IDs)
2,063 B
61,197 B
One module inserted at the front (numeric IDs)
61,788 B
—
Inserted at the front, existing IDs shifted too
3,812 B
—
One module appended at the end (hashed IDs)
64,511 B
5,617 B
Appending at the end came out at exactly the same 2,063 bytes as editing one string. The output files hash differently, so the match is a coincidence — but the order of magnitude is the point: adding a module at the tail costs about what fixing a typo costs.
What actually drove the spread was position. Append at the end and both the function table and the body grow from the back, so no existing offset moves. Insert at the front and every function body downstream slides, which rewrites the table wholesale. My original line — "position made no difference" — was a measurement error on my part: I never separated the front-insertion case out as its own condition.
Hashed IDs appended at the end still cost 64,511 bytes, which I read as the new eight-character string wedging into the string table and moving every string offset after it. That part is inference from the file format; I have not confirmed it.
A bytecode delta is decided less by what you added than by where you added it. It took two passes over my own table to arrive at that sentence.
The awkward part is that real projects do not get to choose the position. Metro numbers modules in import-resolution order, so adding one screen routinely lands the insertion somewhere in the middle. If you cannot pick the position, the only move left is to shrink the region that can move — which is exactly why the split in the next section works.
# Isolate the effect of position alone (reusing gen.py)python3 - <<'GEN'exec(open('gen.py').read().split('variants =')[0])open('tail.js','wb').write(build(num_ids, order, extra=new_num)) # appendedopen('head.js','wb').write(build(num_ids, order, extra=new_num, extra_pos=0)) # insertedGENH=./node_modules/hermes-engine-cli/linux64-bin/hermescfor f in tail head; do "$H" -emit-binary -O -out "$f.hbc" "$f.js"; donepython3 - <<'CMP'import bsdiff4d = lambda a, b: len(bsdiff4.diff(open(a, 'rb').read(), open(b, 'rb').read()))print("appended = {:,} B".format(d('v1.hbc', 'tail.hbc')))print("inserted = {:,} B".format(d('v1.hbc', 'head.hbc')))CMP
What worked: splitting the bundle cut 98%
If the cause is "the whole function table shifts," the fix is to shrink what can shift. Put the parts that rarely change and the parts that change every release into separate bytecode files.
I moved 400 modules into a vendor bundle, kept 80 in the app bundle, and appended the new module only on the app side.
python3 - <<'PY'exec(open('gen.py').read().split('variants =')[0])vendor, app = list(range(400)), list(range(400, 480))open('vendor1.js','wb').write(build(num_ids, vendor))open('app1.js','wb').write(build(num_ids, app))open('vendor2.js','wb').write(build(num_ids, vendor)) # unchangedopen('app2.js','wb').write(build(num_ids, app, extra=new_num)) # one module addedPYH=./node_modules/hermes-engine-cli/linux64-bin/hermescfor f in vendor1 app1 vendor2 app2; do "$H" -emit-binary -O -out "$f.hbc" "$f.js"; donepython3 -c "import bsdiff4d=lambda a,b: len(bsdiff4.diff(open(a,'rb').read(),open(b,'rb').read()))v,a=d('vendor1.hbc','vendor2.hbc'), d('app1.hbc','app2.hbc')print(f'vendor={v:,} B app={a:,} B total={v+a:,} B')"
The result:
Layout
Total delta
vs. single bundle
Single bundle (2.40MB)
61,197 B
—
Split: vendor 2.00MB + app 0.40MB
1,247 B
-98.0%
Splitting has its own trap. Even when the vendor source is effectively unchanged, a different dependency resolution order produces different bytecode. In production I now record the vendor bundle hash on every release so I can see when it was rebuilt without my intending it.
The breakdown was 217 bytes for vendor and 1,030 for the app bundle. That 217 is bsdiff's fixed header cost even for byte-identical input — I confirmed with cmp that the two vendor files match exactly.
The mechanism is simple. The function-table shift is now confined to the 0.40MB app bundle. The 2.00MB vendor bundle is untouched.
The part worth getting right is where you draw the line: split by change frequency, not by feature or layer. React, React Native, navigation, state management — anything that only moves when you bump the SDK — goes on one side. Screens and business logic go on the other. That single decision changes your per-release payload by two orders of magnitude.
Splitting is one decision; where to cut is another, so I measured that too. I pinned the most expensive condition — one module inserted at the front of the app bundle — and varied only how large the app side was.
Modules on the app side
App bytecode
Vendor delta
App delta
Total
40
0.20 MB
228 B
1,021 B
1,249 B
80
0.40 MB
224 B
1,406 B
1,630 B
160
0.80 MB
230 B
1,821 B
2,051 B
240
1.20 MB
223 B
2,303 B
2,526 B
Six times the app bundle bought only 2.3 times the delta. Set against 61,788 bytes for the single bundle, every row here is rounding error.
That took a weight off. Agonizing over the exact boundary does not pay. What matters is that you split at all; where you split is a second-order adjustment — as long as a megabyte or so stays on the vendor side, let your dependency graph decide the line. I had started sorting screens by "how thin can the app side get," and I gave that time back to something else.
Turning this into an operating rule
Here is how the results translated into my own checklist.
Confirm what you are actually shipping. If you ship JS text, most of this is irrelevant; your deltas live in the 191–264 byte range and there is nothing to optimize. This article starts mattering the moment you move to bytecode diffing.
Verify your differ subtracts bytes. If you build a delta pipeline yourself and pick an LZ-based differ, a one-line change will ship tens of kilobytes and you have thrown away most of the point.
Split the bundle by change frequency. Dependencies on one side, app code on the other. This is the single highest-leverage change, and the boundary does not need precision — 0.20 MB and 1.20 MB app sides finished within 1.3KB of each other.
Deprioritize module ID stability. Worth 589 bytes in this measurement. Do it for build reproducibility if you like; do not do it for bandwidth.
Log the actual delta size on every release. One line per release. When a number jumps, you will know what you did that day.
If you can choose where a module lands, the tail is the cheap end — though Metro's resolution order rarely leaves that up to you, which is why splitting beats hoping for a good position. Unlike an App Store release, an OTA goes out with almost no friction, which is exactly why this class of cost stays invisible. One operational habit shifted for me as a result. Adding a single dependency can cost more bandwidth than rewriting a hundred lines of your own code. "Small addition" and "small change" carry very different prices in OTA terms, and I have become noticeably more deliberate about pulling in libraries.
To be straight about it: the bundle is synthetic. Real code has different function granularity and different string-reuse patterns, so you cannot lift these absolute numbers into your own project.
The ratios are structural, though, and I think they hold as an order-of-magnitude guide. "Adding a module costs one to two orders more than changing a string" and "splitting removes most of that" follow from having a function table at all.
Measuring your own bundle is the reliable move. Swap the generation step in gen.py for a read of your real bundle and the rest of the scripts run unchanged.
What the measurement taught me
Had I optimized by intuition, I would have shipped a createModuleIdFactory implementation and felt good about a change worth almost nothing.
The levers that actually mattered were on the side I was not looking at: which differ you use, where you cut the bundle, and where the new module lands. All three are duller than module IDs and all three matter far more.
The other thing I took away was the habit of re-measuring my own published table. The sentence where I decided position was irrelevant did not survive a second run. Numbers you publish deserve one more pass from the person who published them. That order I keep, even in a busy week.
The runs where the numbers contradict you teach the most. This was one of those. Thank you for reading.
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.