<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sunny Luo</title>
    <description>The latest articles on DEV Community by Sunny Luo (@sunnylqm).</description>
    <link>https://dev.to/sunnylqm</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1285489%2F94c16043-86b9-4ffd-91bb-d46df578e2ae.jpeg</url>
      <title>DEV Community: Sunny Luo</title>
      <link>https://dev.to/sunnylqm</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sunnylqm"/>
    <language>en</language>
    <item>
      <title>Why Hermes bytecode defeats binary diffing — and how we got a one-line OTA hotfix down to 3.4 KB</title>
      <dc:creator>Sunny Luo</dc:creator>
      <pubDate>Thu, 08 Oct 2026 10:55:23 +0000</pubDate>
      <link>https://dev.to/sunnylqm/why-hermes-bytecode-defeats-binary-diffing-and-how-we-got-a-one-line-ota-hotfix-down-to-34-kb-2cn2</link>
      <guid>https://dev.to/sunnylqm/why-hermes-bytecode-defeats-binary-diffing-and-how-we-got-a-one-line-ota-hotfix-down-to-34-kb-2cn2</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I maintain &lt;a href="https://github.com/reactnativecn/react-native-update" rel="noopener noreferrer"&gt;react-native-update&lt;/a&gt;, 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.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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. &lt;strong&gt;With Hermes bytecode it isn't&lt;/strong&gt;, and the reason is interesting.&lt;/p&gt;

&lt;p&gt;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):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Release scenario&lt;/th&gt;
&lt;th&gt;Full bundle (gzip)&lt;/th&gt;
&lt;th&gt;Classic bsdiff&lt;/th&gt;
&lt;th&gt;Hermes-aware diff&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One-line text change&lt;/td&gt;
&lt;td&gt;1,544.6 KB&lt;/td&gt;
&lt;td&gt;93.7 KB&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3.4 KB&lt;/strong&gt; (28× smaller)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small feature (+1 component, ~60 LOC)&lt;/td&gt;
&lt;td&gt;1,555.8 KB&lt;/td&gt;
&lt;td&gt;411.6 KB&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;50.2 KB&lt;/strong&gt; (8.2× smaller)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium feature (+2 screens, ~300 LOC)&lt;/td&gt;
&lt;td&gt;1,604.7 KB&lt;/td&gt;
&lt;td&gt;551.6 KB&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;97.8 KB&lt;/strong&gt; (5.6× smaller)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A one-line change producing a &lt;strong&gt;94 KB&lt;/strong&gt; bsdiff patch is the giveaway: something is amplifying tiny source edits into large binary changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: the string table is re-sorted on every compile
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Hermes has a delta mode (&lt;code&gt;hermesc -base-bytecode=&amp;lt;base&amp;gt;&lt;/code&gt;). 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A free 21%: move debug info out
&lt;/h2&gt;

&lt;p&gt;Hermes embeds debug info in the bytecode by default. Compiling with &lt;code&gt;-output-source-map&lt;/code&gt; moves it into an external &lt;code&gt;.map&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: offset tables amplify every insertion
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; 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%.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Making it safe
&lt;/h2&gt;

&lt;p&gt;Clever transforms are only worth it if they never brick an app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bit-exact round trip:&lt;/strong&gt; every patch is checked so that &lt;code&gt;inverseTransform(patch(transform(old))) == new&lt;/code&gt;, byte for byte.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instruction-level equivalence:&lt;/strong&gt; 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 &lt;code&gt;UIntSwitchImm&lt;/code&gt; jump tables and silently threw away valid delta builds. That's fixed now.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Hermes-specific client code:&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;The benchmark harness and raw data are here: &lt;a href="https://github.com/sunnylqm/hbc-diff-benchmark" rel="noopener noreferrer"&gt;https://github.com/sunnylqm/hbc-diff-benchmark&lt;/a&gt;. I'd love to see numbers from other apps, especially large ones. If your results differ, open an issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it
&lt;/h2&gt;

&lt;p&gt;The diffing is built into &lt;a href="https://github.com/reactnativecn/react-native-update" rel="noopener noreferrer"&gt;react-native-update&lt;/a&gt; (open source, around since 2016) and the hosted service &lt;a href="https://cresc.dev" rel="noopener noreferrer"&gt;Cresc&lt;/a&gt;. There's a free tier, and teams moving off CodePush can use the &lt;a href="https://cresc.dev/codepush-migration-checklist" rel="noopener noreferrer"&gt;migration checklist&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Questions about Hermes internals or OTA pipelines are welcome in the comments.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>hermes</category>
      <category>mobile</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
