← All posts
bsdiff

What is Bundle Diffing (bsdiff) in OTA Updates

How bsdiff reduces React Native and Expo OTA update sizes, how much bandwidth it saves, and when it’s less effective.

Maybe you’ve already heard of Bundle diffing (bsdiff) in React Native OTA updates, maybe not. We’ll look at what it is in practice, and how Hermes (React Native’s JS engine) actually runs your JS.

Hermes

On July 12, 2019, at Chain React, React Native announced Hermes: a JavaScript engine built to replace JavaScriptCore, which RN had been using since 2015.

To get why Hermes exists, you first need to see what happens to your nice React Native code before a phone can run it:

Before Hermes, the JS bundle produced by Metro & Babel was executed directly by JavaScriptCore on the phone.

The problem with that JS bundle is the format: text. A JavaScript engine has to parse it before it can run it, and that parsing can take a long time at app launch.

Hermes fixes this by compiling the code to bytecode, which the engine can run directly: no parsing step, a much smaller bundle, and better memory use.

OTA updates

Hermes barely changed how OTA update protocols work for React Native apps. The one real change is the bundle file the server sends: it’s now a .hbc file, Hermes bytecode.

bsdiff

In 2003, Colin Percival, a PhD student at Oxford, published a paper: Naive differences of executable code.

He describes an algorithm (bsdiff / bspatch) to ship binary updates for FreeBSD: send only the “delta”, instead of making every machine re-download the full binary.

He wasn’t the first to work on this kind of algorithm, but existing tools all had the same problem: a tiny change in the source cascaded into pointer changes and produced a binary that looked completely different, so the patch was huge.

This illustration sums it up more simply:

The idea: find sequences that almost match, then send the small byte tweaks instead of rewriting the whole block.

Bundle diffing on React-native OTA Updates

You’ve got the idea: run bsdiff on the .hbc bytecode file Hermes produces, and OTA updates get smaller: Less egress, shorter downloads.

Benefits

Expo announced ~75% smaller downloads with bundle diffing (https://expo.dev/blog/ship-smaller-ota-updates-bundle-diffing-comes-to-ota-updates-in-sdk-55), with an example of 3MB → 0.75MB.

Take that with a grain of salt. A bsdiff patch depends on a lot of factors, and Expo’s post doesn’t go into the technical details.

Paradox

The tradeoffs matter. Bundle diffing is not magic, and no, you will not get a 75% reduction on every patch.

I put together a repo to show that paradox, and the confusion around bundle diffing. You’ll find a few examples in this repository.

There’s a small sample app in there. We’ll run a few operations on it:

base.js

// A deliberately tiny "React Native" app, written the way Metro serialises a
// bundle: every module is a factory function registered with __d(), and
// required through __r(). There is no real React here. What matters for Hermes
// is the *shape* of the code (identifiers, property names, string literals,
// functions), not what it does at runtime.

var __modules = {};
function __d(factory, moduleId, dependencyMap) {
  __modules[moduleId] = { factory, dependencyMap, exports: {}, loaded: false };
}
function __r(moduleId) {
  var m = __modules[moduleId];
  if (!m.loaded) {
    m.loaded = true;
    m.factory(globalThis, __r, m, m.exports, m.dependencyMap);
  }
  return m.exports;
}

// --- module 0: a fake, minimal react-native --------------------------------
__d(function (global, require, module, exports) {
  exports.View = 'View';
  exports.Text = 'Text';
  exports.Pressable = 'Pressable';
  exports.StyleSheet = { create: (styles) => styles };
  exports.createElement = (type, props, ...children) => ({ type, props, children });
}, 0, []);

// --- module 1: config -------------------------------------------------------
__d(function (global, require, module, exports) {
  exports.API_URL = 'https://api.example.com';
  exports.PAGE_SIZE = 20;
}, 1, []);

// --- module 2: the screen ---------------------------------------------------
__d(function (global, require, module, exports, dependencyMap) {
  const { View, Text, Pressable, StyleSheet, createElement: h } = require(dependencyMap[0]);
  const { API_URL, PAGE_SIZE } = require(dependencyMap[1]);

  const styles = StyleSheet.create({
    container: { flex: 1, padding: 16 },
    title: { fontSize: 20, fontWeight: 'bold' },
    row: { flexDirection: 'row', alignItems: 'center' },
    label: { fontSize: 14, color: '#333' },
  });

  function Header({ title }) {
    return h(View, { style: styles.row }, h(Text, { style: styles.title }, title));
  }

  function Item({ item, onSelect }) {
    return h(
      Pressable,
      { style: styles.row, onPress: () => onSelect(item.id) },
      h(Text, { style: styles.label }, item.name),
    );
  }

  function List({ items, onSelect }) {
    const rows = [];
    for (let i = 0; i < items.length && i < PAGE_SIZE; i++) {
      rows.push(h(Item, { key: items[i].id, item: items[i], onSelect }));
    }
    return h(View, { style: styles.container }, rows);
  }

  function fetchItems(page) {
    return fetch(`${API_URL}/items?page=${page}&size=${PAGE_SIZE}`).then((r) => r.json());
  }

  function App() {
    return h(
      View,
      { style: styles.container },
      h(Header, { title: 'Inbox' }),
      h(List, { items: [], onSelect: (id) => console.log('selected', id) }),
    );
  }

  module.exports = { App, fetchItems };
}, 2, [0, 1]);

__r(2);


update-1-rename-variables.js

This update renames 6 variables in the file.

update-2-add-one-prop.js

This update adds a prop to a View.

update-3-add-function.js

This update adds a 12-line onPress handler to a view.


You’d naturally think update 2 is the lightest, and update 3 would change the most.

After computing the patches, here’s what you actually get:

updatelines touchedbundle Δbsdiff patch
no change00 B137 B
rename 6 variables160 B302 B
add one prop1+44 B732 B
add a 12-line function12+250 B656 B

Why ?

This comes from how Hermes works. At compile time it doesn’t store variable names, just a number in a register. That’s why renaming styles to sheet doesn’t change a single instruction.

For update 2, Hermes keeps a global table of every string: props, literals, and so on.

You’ll get something like:

IndexValue
0'string1'
1'string2'
2'string3'
.......

If you insert a new prop in the code, like testID: 'header', you can shift the whole index:

IndexValue
0'string1'
1'header'
2'string2'
3'string3'
.......

Those indexes are referenced and baked into every function at compile time, which can rewrite a large part of the bundle.

Memory

You also have to understand that computing a patch between two binaries is RAM-heavy:

bsdiff requires max(17n, 9n+m)+O(1) bytes of memory

So running bsdiff on a 9MB bundle can take up to ~150MB of RAM on the server.

The device then has to run bspatch to rebuild the bundle. It keeps the old and the new bundle in memory at the same time: about 18MB for a 9MB bundle, often at app launch, sometimes on phones with very little RAM.

EAS Update

That RAM cost is a real problem for hosted services like Expo Application Services. Picture scaling an infra with a generous free plan that can burn through huge amounts of RAM. That’s why Expo writes this in the bundle diffing post:

If patch generation is too resource-intensive, EAS Update again falls back to the full bundle.

If a giant like Expo can skip or reject a bundle-diffing computation, you can imagine that’s the case for almost every OTA SaaS.

Conclusion

Bundle diffing for React Native / Expo OTA is genuinely useful. In a lot of cases it cuts egress and the time devices spend downloading an update.

The server still has to treat patches as a maybe, not a given:

  1. Drop patches that are not worth serving.
  2. Precompute them at publish time, so the device is not waiting on a diff.
  3. Let you see, per update, what happened: which patches were kept or skipped, and what you actually saved.

The client also has to fall back to a full bundle when anything looks off: I strongly recommend expo-updates.

If you want this out of the box, xprem (self hosted OSS OTA Updates server for your Expo Apps) ships in v3.2.0 bundle diffing with a solid bsdiff implementation.

You can configure exactly where the threshold sits between a good and a bad patch, add a size constraint on bundles to protect your server's memory, and monitor every patch directly in your dashboard: