Shipping a JavaScript-only fix to a mobile app without a store review is the appeal of over-the-air updates. The catch is that the JavaScript runs inside a binary that was built earlier and cannot change. If the new JavaScript calls a native module the old binary does not contain, the app breaks.
Systems programmers know this problem under another name. A program compiled against version 1 of a shared library, loaded against a version that changed a struct layout, crashes in odd places. The fix there is an ABI version: a number that says “binaries and libraries with the same number can be mixed”. An update’s runtime version is that number.
I looked at how Expo computes it automatically, with the fingerprint policy, to see what the number reacts to.
TL;DR
- Expo’s runtime version is “a property that guarantees compatibility between a build’s native code and an update”. An update is offered only to builds with an equal runtime version string. That is the whole mechanism.
- A hand-maintained string works until somebody forgets to change it. The docs’ own example is an update that uses a native module the build does not have;
expo-updates“may detect an error and attempt to roll back”. - The
fingerprintpolicy derives the string by hashing dependencies, native files and configuration with@expo/fingerprint. In my tests with version 0.20.13 it ignored a JavaScript edit and a pure-JS dependency, and it changed for a new native module, as intended. - It also changed for edits you may think of as JavaScript-adjacent:
extra,version,ios.buildNumberandandroid.versionCode. Changing the hash means existing installs stop receiving updates until a new binary ships. - Setting
sourceSkipsreplaces the defaults. In my test, a config that skipped only the version fields andextramade an edit to apackage.jsonscript change the hash again, because the default skip for such scripts had been dropped. - All of this is the behavior of one package version, measured locally. I did not run EAS, a device, or an update server.
The model
The update protocol says the same thing in its own words. The client sends expo-runtime-version, which “stipulates the native code setup a client is running”. A manifest’s runtimeVersion says what native setup is required to run that update. The server picks the newest update that satisfies the constraints.
Nothing in that exchange inspects code. The string is a promise made by whoever sets it. The simulation below, which I labeled as a simulation because it does not involve Expo or a device, shows the three outcomes that matter:
== hand-maintained runtime string, native module added, string not bumped
served: u2
device result: FAILS: native module(s) missing: expo-camera
== derived runtime string: same change
runtime strings: installed=fp-727073d0 u2=fp-47831473
served: u1 (u2 is held back until a new binary exists)
device result: ok
== a JS-only value hashed into the runtime string
before=fp-39a097af after=fp-28bb4ee8 equal=false
-> every existing install stops receiving updates, though nothing native changed
A derived string fixes the first failure and creates the possibility of the third. How often the third happens depends on what goes into the hash.
What the fingerprint hashes
I created a minimal project (Expo 57.0.26, React Native 0.87.1, @expo/fingerprint 0.20.13) with runtimeVersion: { policy: "fingerprint" }, an extra.apiUrl, a version, a build number and a version code. I generated a fingerprint, changed one thing, generated again, and compared. The command is the package’s own CLI (npx @expo/fingerprint fingerprint:generate). The fingerprint policy in Expo’s docs is described as using this package, but I called the CLI directly and did not check that the policy applies the same default configuration, so read the table as the package’s behavior.
| Change | Hash |
|---|---|
| Edit a JavaScript file | same |
| Add a pure-JS dependency (lodash) | same |
Add a native module (expo-camera) | changed |
| Remove either again | back to the baseline hash |
extra.apiUrl | changed |
version 1.0.0 → 1.0.1 | changed |
ios.buildNumber 1 → 2 | changed |
android.versionCode 1 → 2 | changed |
App name | changed |
ios.infoPlist usage string added | changed |
package.json script android edited (no “run” in it) | same |
The pattern is sensible for a native-compatibility hash: anything that ends up in the native project (the display name, a permission string, a build number) changes the hash. A new native module changes it through the files it adds to the autolinking inputs. extra is different. It is a bag of values your JavaScript reads, but it is part of the app config, and the default configuration hashes it. The documentation’s SourceSkips table has a separate entry, ExpoConfigExtraSection, for leaving it out.
The documentation lists .fingerprintignore, fileHookTransform, extraSources and sourceSkips as ways to change what is hashed. It also lists a limitation worth knowing: for config plugins written as raw functions, only the function’s name is fingerprinted, so editing the body of an anonymous plugin does not change the hash.
The skip list that drops a default
sourceSkips takes either a bitmask or an array of names, and the documentation’s SourceSkips table includes ExpoConfigVersions and ExpoConfigExtraSection. I wanted an extra change and a version bump to leave the hash alone, so I wrote:
// fingerprint.config.js
module.exports = { sourceSkips: ['ExpoConfigVersions', 'ExpoConfigExtraSection'] };
That did what I wanted. version, ios.buildNumber, android.versionCode and extra.apiUrl no longer changed the hash. The app name and the infoPlist string still did, which is right. But a different edit changed too:
package.json scripts.android edited (no "run") default config: same with my sourceSkips: CHANGED
The default configuration skips scripts of that kind. Supplying sourceSkips replaced the default list instead of adding to it. When I wrote the default skip back explicitly ('PackageJsonAndroidAndIosScriptsIfNotContainRun'), the edit was ignored again. The docs’ own example for fingerprint.config.js lists that name next to ExpoConfigVersions, which I now read as a hint.
The lesson is general. A configuration that overrides a list may remove entries you did not know were in it. After any change to the skip list, rerun the table above.
A practical policy
- Decide which edits should and should not start a new native build, and write the list down. The fingerprint is how you enforce it, not how you decide it.
- In CI, compute the fingerprint on every pull request and compare it with the one recorded for the last binary you shipped.
fingerprint:difflists the sources that differ, which tells the reviewer why. A changed hash means “this change needs a new store build”, and the review can say so. - Treat
extradeliberately. If it holds environment values you change often, either accept that every change needs a new binary, or move those values out of the app config (a remote config fetched at start-up, for instance) or addExpoConfigExtraSectionto the skips. Before skipping it, check how your app reads those values after an update. I did not test that. - When you override
sourceSkips, copy the defaults in on purpose. - If you prefer to set the string yourself (for example a bumped
appVersion), keep it, and add the CI check anyway: a fingerprint that differs from the last release while the runtime version string did not change is exactly the situation that breaks users.
Fingerprint your own project
mkdir fp-lab && cd fp-lab
# put package.json, app.json, index.js and experiment.mjs from the lab here, then:
npm install --ignore-scripts --legacy-peer-deps
node experiment.mjs # three configs x the scenarios above, then the dependency scenarios
node ../update-compat/sim.mjs # the three outcomes of the model, as a simulation
On a real project, npx @expo/fingerprint fingerprint:generate > a.json, make a change, generate b.json, then npx @expo/fingerprint fingerprint:diff a.json b.json.
Ways the hash surprises you
- A runtime version nobody bumps. Symptom: after an update that adds a native module, users on older builds crash or fall back. Fix: use the fingerprint policy or a CI check.
- Config edits that look harmless. Symptom: you change
extra.apiUrlor the app version, the runtime version changes, and existing installs stop getting updates, with no error. Fix: the CI diff, plus a decision on whether those values belong in the hash. - A skip list that replaces the defaults. Symptom: unrelated edits start changing the hash after you add a skip. Fix: include the defaults explicitly and rerun the experiment.
- Anonymous config plugins. Symptom: you change a plugin’s behavior and the fingerprint stays the same. The docs describe this limitation. Fix: give raw plugin functions a name, or hash their source with an extra source.
- Native changes outside the tracked files. Symptom: a binary differs in ways the hash cannot see. I did not test this and cannot give a concrete case; it is the general weakness of any derived version. Fix: keep the human process (a checklist for native changes) alongside the automatic one.
- Different platforms. Symptom: an Android-only native change forces iOS users to a new build or the reverse. The docs say a platform-specific
runtimeVersionoverrides the top-level one. Fix: use platform-specific values if the platforms really diverge. I did not test that.
Derived or hand-written?
Use a derived version when several people change dependencies and config, because forgetting is the dominant failure. Use a hand-maintained one when you ship few native changes and want to control when the line is crossed, with the CI check as a safety net.
If your app is JavaScript-only plus Expo’s own modules, and you always ship a new build for any dependency change, the runtime version is mostly a formality; the cost is the same either way.
What the CLI showed, and what I did not run
Verified with Node.js 22.23.3, Expo 57.0.26, React Native 0.87.1 and @expo/fingerprint 0.20.13: every row in the table above (generating a fingerprint before and after each edit, using the CLI in a minimal project), the effect of sourceSkips in the two forms, and that writing the default skip back restores the behavior. The model in the simulation is my own and has no Expo code in it. I read the runtime versions page, the update protocol page and the fingerprint page of the Expo documentation on the day of writing.
Not verified: any real update server, EAS, or a device, and the expo-updates policy itself (only the package it uses). I did not check what happens on a device when the update and the build disagree; the “may roll back” wording is the documentation’s. I did not run other versions of the fingerprint package; a prerelease tagged next was published when I looked, and its behavior may differ. I did not test .fingerprintignore, fileHookTransform, or the platform-specific runtimeVersion override.
Read the hash before you rely on it
Setting the runtime version by hand makes a person responsible for deciding when two artifacts can be mixed. Deriving it hands that decision to a hash, and a hash can be too strict as easily as too loose. Generate the fingerprint, change one thing at a time, and see what it reacts to before you rely on it.