How React Native Actually Works: Architecture, Hermes, and OTA Updates
Building Dream Decoder taught me that not all React Native changes are equal. Some need a new binary submitted to the store. Others can go straight to users in minutes. The difference comes down to one question: did native code change?
To answer that confidently, you need to understand what's actually inside your APK, how JavaScript talks to native code at runtime, and how EAS Update's delivery model works under the hood. That's what this article covers.
What's inside your APK
When you build a React Native app, the output is not a compiled native binary in the traditional sense. Open the APK as a zip and you'll find three things that matter:
com.yourapp.apk/
|-- lib/
| |-- arm64-v8a/
| | |-- libhermes.so [1]
| | |-- libreactnative.so[2]
| | |-- libc++_shared.so [3]
|-- assets/
| |-- index.android.bundle [4]
|-- classes.dex [5]
| # | Component | Purpose |
|---|---|---|
| 1 | `libhermes.so` | Native Hermes JavaScript engine |
| 2 | `libreactnative.so` | React Native runtime and JSI |
| 3 | `libc++_shared.so` | Shared C++ runtime library |
| 4 | `index.android.bundle` | Your compiled JavaScript/Hermes bytecode |
| 5 | `classes.dex` | Android bootstrap code |
The index.android.bundle is not your source code. Hermes compiled it into bytecode at build time — a compact binary format that executes much faster than raw JavaScript text. Because that file is just a file on disk, it is also extractable and readable by anyone who has your APK. Never put API keys, secrets, or critical authorization logic in JavaScript. The bundle is not protected storage.
Each app carries its own copy of Hermes. Android provides no shared JS runtime the way the OS provides a Java runtime. That's a known cost roughly 2–3 MB per app and Hermes was specifically designed by Meta to keep that number low compared to JavaScriptCore, which React Native used before.
How the app boots
When a user taps your icon, the sequence is:
Android creates an isolated process for your app
classes.dexruns — the Java/Kotlin bootstrap initializeslibhermes.soandlibreactnative.soload into memoryA dedicated JS thread starts alongside the UI thread
Hermes reads
index.android.bundlefrom disk and executes the bytecode
At step 5, your components register themselves in JS memory. Nothing is on screen yet. The UI thread is ready to draw; it's waiting for instructions.
The old bridge vs JSI
In the original React Native architecture, the JS thread and the UI thread communicated through a message queue. Every instruction was serialized to JSON, placed in the queue, and deserialized on the other side. This was inherently async and added real overhead which lead to large data transfers being slow and heavy UI screens feeling laggy.
JSI (JavaScript Interface) replaced this. It gives Hermes a direct C++ reference to native objects — not a JSON description of them, the actual object. Calling a native function from JS via JSI is synchronous and requires no serialization.
The practical difference is significant. The React Native docs note that a 30 MB camera frame buffer, roughly 2 GB of data per second at typical frame rates, that would have been unusable over the old bridge is handled without issue via JSI.
Since React Native 0.76, the New Architecture (JSI + Fabric renderer + TurboModules) is enabled by default in all new projects. Fabric is the new rendering system built on JSI that handles UI updates more efficiently. TurboModules are native modules that load lazily and communicate directly via JSI instead of the old bridge. If you want to go deeper on these specifically, the articles linked at the end cover them in detail.
What happens when you tap a button
Take this component:
function Counter() {
const [count, setCount] = useState(0)
return (
<View>
<Text>{count}</Text>
<Button onPress={() => setCount(count + 1)} title="Add" />
</View>
)
}
When React Native creates the native Button via JSI, it also registers a callback on it pointing back to a JS function. The native button handles the touch entirely on its own and the OS delivers the touch event to Android's native Button view, which fires its own internal touch handler. That handler then calls the JSI callback registered at creation time, which notifies the JS thread that onPress fired.
The JS thread executes your handler, React produces a new virtual DOM, diffs it against the old one, and sends a minimal update instruction to the UI thread: "update this Text node's content." The UI thread updates only that view.
What the user sees are real native Android views likeandroid.widget.Button, android.widget.TextView. Not a canvas, not a WebView. React Native produces instructions for native views; it does not paint its own UI.
How Hermes executes JavaScript on a device that doesn't understand it
Android's OS understands machine code for its CPU (ARM architecture). It does not understand JavaScript. Hermes bridges this in two stages.
Build time: Your JS source is compiled to Hermes bytecode. Bytecode is not machine code yet, it's an intermediate format specific to Hermes. The advantage of stopping here rather than compiling all the way to machine code is portability: one bytecode bundle works on ARM v7, ARM v8, x86, and x86_64 without change. Hermes handles the final translation per device at runtime.
Runtime: When the app launches, Hermes loads the precompiled bytecode from index.android.bundle and executes it using its bytecode interpreter. Because parsing and compilation happened at build time, startup is much faster than executing raw JavaScript source. As your code runs, Hermes performs various runtime optimizations, but it does not use a traditional Just-In-Time (JIT) compiler in React Native. The bytecode is interpreted directly by the Hermes engine, which is implemented as native C++ code.
libhermes.so consists of compiled C++ machine code. Since the CPU can only process machine code, the Hermes engine acts as an intermediary; it runs the C++ code, which then executes your bytecode instructions. Consequently, your JavaScript never interacts with the CPU directly.
What OTA updates actually swap
An OTA update does not update your app. It updates one file: index.android.bundle.
Every app gets a private data directory on disk that only it can read and write:
/data/data/com.yourapp/
└── files/
└── updates/
└── index.android.bundle ← expo-updates writes
When expo-updates downloads a new bundle from EAS servers, it saves it here — not inside the APK, which the OS locks. At the next launch, expo-updates runs before Hermes loads anything and checks: is there a downloaded bundle in this directory? If yes, tell Hermes to load that one instead of the one embedded in the APK.
At a high level, that's the entire mechanism. Hermes loads a file from disk. expo-updates decides which file.
What this means for native code: libhermes.so, libreactnative.so, and all other .so files are compiled machine code baked into the binary. They cannot be swapped at runtime. The OS won't allow it and there is no mechanism to do it. If your update requires a native change such as a new native module, an updated Expo SDK, a config plugin change then no bundle swap can deliver it. You need a new binary submitted to the store.
EAS Update: channels, branches, and runtime versions
EAS Update separates three concepts that are easy to conflate:
Branch — where your OTA updates are stored. When you run eas update, a new bundle is published to a branch. Think of it as a git branch for update bundles.
Channel — a label baked into your binary at build time. The binary only ever checks one channel for updates. You control which branch a channel points to, and you can change that mapping without rebuilding anything.
Runtime version — a compatibility label stamped on both the binary and the update. EAS will only deliver an update to a binary if their runtime versions match exactly. This is the mechanism that prevents a bundle built against new native code from landing on a binary that doesn't have it.
The recommended policy:
{
"expo": {
"runtimeVersion": { "policy": "appVersion" }
}
}
With appVersion, the runtime version is your app's version number (1.0.0, 1.1.0, etc.). A binary built as 1.0.0 only receives updates labeled 1.0.0. When you ship a new binary with a new store version, it gets a different runtime version, old binaries never receive updates intended for new ones.
The channel rerouting capability matters in incidents: if a bad update goes out, you can point the production channel back to a stable branch in seconds without touching any binary:
eas channel:edit production --branch stable
Every user's app then pulls from the stable branch on next launch. No store submission, no review wait.
How to detect when you need a new build
The question appVersion policy doesn't answer is: when do you actually need to bump the version and submit a new binary?
The answer: when native code changed. And you often can't tell by reading a package.json diff whether a dependency upgrade touched native code or not.
Expo fingerprinting handles this. It hashes your entire native surface area — npm dependencies, config plugins, native code files into a single checksum. Compare your local project's fingerprint against a production build:
eas fingerprint:compare --build-id <BUILD-ID>
If the fingerprints match, native code is unchanged and the update is safe to ship OTA. If they differ, something touched native code and you need a new binary first.
The most common production crash pattern in React Native apps: a team assumes a dependency is JavaScript-only when it quietly ships a native module. Fingerprinting makes this detectable before it reaches users.
App Store policies on OTA updates
Both Google Play and Apple's App Store permit OTA updates that swap JavaScript bundles.
Apple's App Store Review Guidelines (section 2.5.2) allow interpreted code delivered post-review as long as the update does not change the app's primary purpose or introduce functionality that would have required review.
Google Play allows JavaScript updates but strictly prohibits using them to bypass review, mislead users, or alter an app's primary purpose. Any mechanism that changes an app's behavior to perform undisclosed actions is explicitly banned as a policy violation.
Summary
React Native apps contain a Hermes bytecode bundle, a copy of the Hermes engine, and native code. Hermes executes your JavaScript by translating bytecode to ARM machine instructions at runtime. JS communicates with native views via JSI — direct C++ references, synchronous, no serialization. The output is real native views.
OTA updates work by swapping the bytecode bundle in the app's private data directory. Everything else: the engine, the native modules, the binary stays the same. This is why OTA updates can only carry JavaScript changes. Fingerprinting tells you when native code has changed and a new store binary is required. Runtime version policies enforce that the right updates reach the right binaries.
The mechanism is not magic. It is file replacement with a compatibility firewall.
Further reading
For a deeper look at JSI, TurboModules, Fabric, and Yoga — the full new architecture stack:



