Welcome, Developer đź‘‹
Expo app versioning is simple… until it isn’t.
It works perfectly — right up to the moment you accidentally ship an OTA update to the wrong runtime and production devices silently break.
That’s when you realize versioning isn’t a configuration detail.
It’s a release governance system.
In this post, I’ll walk through how I manage versioning in production Expo apps using:
- Fingerprint-based runtime versions
- Strict SemVer discipline
- Deterministic release branches
- Immutable production tags
- Controlled EAS workflows
There are many valid approaches. This is simply the one that has proven resilient for my app under real-world constraints.
Mental Model: Three Versions + One Source of Truth
I split versioning across three layers. Each layer has a distinct responsibility.
1. App Version (SemVer) — Product Communication
Format: MAJOR.MINOR.PATCH
How I apply it:
- PATCH → fixes, small improvements, OTA-only changes
- MINOR → new capabilities or visible feature increments
- MAJOR → rare, structural shifts
SemVer communicates product evolution — not runtime compatibility.
2. Build Version — Native Binary Identity
Defined in your Expo config (app.json or app.config.js):
- iOS →
buildNumber - Android →
versionCode
Rules:
- Must increment on every native submission
- Must never go backwards
- Must remain traceable to a release line
I personally prefer setting cli.appVersionSource to "remote" in eas.json.
This allows EAS servers to manage android.versionCode and ios.buildNumber remotely.
Then, in the production build profile, I enable:
"autoIncrement": trueThis ensures build numbers are automatically incremented during native builds, reducing manual errors.
3. Runtime Version — OTA Compatibility Boundary
An over-the-air (OTA) update applies only if:
build.runtimeVersion === update.runtimeVersion
For my app, the runtime version is configured as:
"runtimeVersion": { "policy": "fingerprint" }That means:
- Fingerprint changes → runtime changes
- Runtime changes → new native build required
- Fingerprint unchanged → OTA-only release allowed
The fingerprint decides. Humans don’t.
The Single Source of Truth: Fingerprint
If you take only one thing from this post, take this:
- Fingerprint changed → Native release
- Fingerprint unchanged → OTA release
Not “what we feel like.”
Not “it’s just JS.”
Not “it should be fine.”
Fingerprint is deterministic. That’s why it’s powerful.
What This Looks Like in Expo config
{
"expo": {
"name": "The App",
"slug": "the-app",
"version": "4.0.0",
"ios": { "buildNumber": "4" },
"android": { "versionCode": 4 },
"runtimeVersion": { "policy": "fingerprint" },
"updates": { "url": "https://u.expo.dev/<project-id>" }
}
}You can omit buildNumber and versionCode if using EAS remote versioning with auto-increment.
Key responsibilities:
expo.version→ product communicationbuildNumber/versionCode→ native identityruntimeVersion.policy = fingerprint→ OTA safety boundary
Fingerprint Governance
Fingerprint-based runtime versioning is only as reliable as your fingerprint inputs.
By default, the fingerprint algorithm considers a wide set of native-relevant inputs (dependencies, config, native folders, etc.). That’s great — until you realize some files should not force a new runtime.
This is where teams either:
- get runtime churn (too many unnecessary native releases), or
- get runtime drift (fingerprint ignores something it shouldn’t), which is worse
To keep fingerprinting aligned with reality, I treat fingerprint configuration as part of release governance.
What fingerprint should represent
In practice, I want the fingerprint to change when (and only when) a new binary is required.
So the fingerprint should include inputs that affect:
- Native code generation
- Config plugins / prebuild output
- App identifiers, entitlements, permissions
- Native dependencies
- Anything that changes what iOS/Android compile
And it should ignore inputs that don’t change native output, such as:
- Docs
- Screenshots
- Test fixtures
- Non-shipping tooling
- Local scripts
- CI-only metadata
fingerprint.config — define what matters
A fingerprint.config file allows you to explicitly control what contributes to the runtime hash.
Think of it as your runtime contract: what changes should force a new runtime version?
For example, I might not want to trigger a new native build when I change the expo.version property value.
/** @type {import('@expo/fingerprint').Config} */
const config = {
sourceSkips: [
"PackageJsonAndroidAndIosScriptsIfNotContainRun",
"ExpoConfigNames",
"ExpoConfigExtraSection",
"ExpoConfigVersions",
],
};
module.exports = config;This tells the fingerprint engine:
Do not include these specific sources when calculating the fingerprint.
In other words, changes in those areas will not trigger a new runtime version.
That directly affects whether your pipeline decides:
- OTA release
- or native rebuild
This file is not cosmetic. It modifies your runtime compatibility boundary.
If your team changes what belongs in this contract, treat it like a platform decision — because it affects when native releases occur.
Learn more in the Expo documentation: https://docs.expo.dev/versions/latest/sdk/fingerprint
Conclusion
Versioning doesn’t need to be complicated. But it must be intentional.
In this model:
- SemVer tells users what changed.
- Build numbers satisfy store requirements.
- Fingerprint-based runtime decides compatibility.
- Branches and tags keep production traceable and rollback-safe.
And the key rule is simple:
If the fingerprint changes, we build.
If it doesn’t, we ship OTA.
No guessing, debate, or “it should be fine”.
When versioning is deterministic, releases become predictable — and predictable systems are what allow teams to move fast without breaking production.
Thank you for reading, Developer!