“Expected 96 but got 98”: Hermes bytecode versions and OTA compatibility
Hermes compiles your JavaScript to bytecode at build time, and the runtime refuses bytecode from a different encoding version. Here is how that interacts with OTA updates, and the rules that keep you out of trouble.
Sooner or later, a React Native team shipping OTA updates meets a crash log that says something like Wrong bytecode version. Expected 96 but got 98. It looks cryptic and it is actually one of the most mechanical failures in the whole stack — which makes it very preventable once you know what the numbers mean.
What Hermes does with your JavaScript
With Hermes — the default engine for React Native — your JavaScript isn't shipped as text. At build time it's compiled ahead-of-time into Hermes bytecode (HBC), and the Hermes runtime inside your native binary executes that bytecode directly. This is a big startup win, and it introduces a contract: the bytecode format itself is versioned. The version is a plain integer that the Hermes team bumps whenever the on-disk encoding changes, and a Hermes runtime only loads bytecode whose version matches what it was built to read.
Two properties of that version number trip people up. First, it is independent of React Native's version number — there is no formula mapping RN 0.8x to bytecode version y; the integer moves when the encoding changes and not otherwise. Second, it is strict by design. There's no "close enough" — a mismatch is a refusal, not a warning.
Where OTA turns this into an incident
Within a normal store release, you never notice any of this: the bundle and the runtime are built together, so they always match. OTA breaks that guarantee. Your update is compiled today, with today's toolchain, and delivered to a binary you built weeks or months ago. If anything in between changed the Hermes version — a React Native upgrade, a CI image update, a teammate building from a different branch — you can compile an update the installed runtime cannot load.
What happens next depends on your update client. The good failure is detection: the update is rejected and the app keeps running its previous bundle — stale but alive. The bad failure is a crash on load, caught (hopefully) by a rollback mechanism. Either way, the deploy looked green in CI, and your users didn't get the fix. It's the same silent-staleness family of failure as updates that never apply — invisible unless you're measuring adoption.
Matching versions is necessary — and not sufficient
Here's the subtlety that even careful teams miss: identical bytecode versions guarantee the runtime can execute your bundle. They don't guarantee the JavaScript environment matches what your code expects. Two React Native releases can share a bytecode version while differing in JS APIs, polyfills, or native-module surface. Bytecode version is a floor check, not a compatibility proof. The reliable rule is stricter and simpler:
- Compile updates with the same React Native and Hermes versions as the binary you're targeting. Not "compatible" versions — the same ones.
- Target updates at explicit app versions, so a bundle built for the 1.8.0 binary can never land on 1.6.2. This is precisely why update targeting exists.
- Pin your build environment. Lock toolchain versions in CI and build updates from the release branch or tag of the binary they're for, not from main.
- After any RN upgrade, treat the new binary as a new world. The first release on the upgraded runtime goes through the stores; OTA resumes after. (The native/JS boundary post covers why engine upgrades are always native releases.)
It gets more interesting from here
The Hermes side of React Native is moving again — React Native 0.82 introduced Hermes V1 as an experimental opt-in. It is a deliberate native runtime choice, not a drop-in OTA change. Engine transitions are exactly when bytecode assumptions churn, so the discipline above is about to pay for itself. If you run a fleet of binaries across several RN versions, keep a small table: app version → RN version → toolchain. It's ten minutes of bookkeeping, and it turns "why is 1.6 crashing" from an investigation into a lookup.