Few production incidents are as frustrating as a silent failure in Over-The-Air (OTA) updates. Your CI/CD pipeline reports a successful deployment via eas update. The bundle uploads to the Expo cloud. Yet, end-users report that bugs remain fixed, or worse, the application crashes immediately upon receiving the update.
In 90% of these cases, the culprit is not bad JavaScript code. It is a misalignment between the Runtime Version of the native binary installed on the device and the target runtime version of the OTA update.
This guide provides a rigorous technical breakdown of why this mismatch occurs, how the Expo Updates protocol evaluates compatibility, and how to implement a fail-safe configuration in your app.config.ts and CI/CD pipelines.
The Anatomy of a Mismatch
To solve the problem, we must first understand the mechanism of failure. When an Expo app launches, the expo-updates native module performs a handshake with the update server. It sends several headers, but the most critical is expo-runtime-version.
The server compares this header against available update bundles.
- If the strings match exacty: The server delivers the new JavaScript bundle.
- If the strings differ: The server denies the update, and the app continues running the embedded bundle.
The Hidden Risk: Native Dependency Drift
A common anti-pattern is setting the runtimeVersion equal to the sdkVersion (e.g., "50.0.0") or a static string (e.g., "1.0.0") in app.json.
Consider this CI/CD scenario:
- You install a native package, such as
react-native-ble-plx(Bluetooth). - This requires native code changes (modifying AndroidManifest or Podfiles).
- You push code. CI runs
eas update. - Because the static
runtimeVersion("1.0.0") hasn't changed, the server delivers this new JS bundle to devices running the old binary. - Crash: The JS bundle attempts to import the Bluetooth module. The old binary does not contain the native Java/Obj-C bindings for it. The app throws a fatal error.
The Solution: Policy-Based Runtime Versioning
Hardcoding strings is error-prone. The robust solution is to programmatically derive the runtime version based on the actual state of your native code. Expo provides a "fingerprint" policy, but for enterprise control, we often need a hybrid approach using app.config.ts.
Method 1: The Fingerprint Policy (Recommended)
The most secure method for modern Expo apps (SDK 48+) is the fingerprint policy. This generates a unique hash based specifically on files that impact native build artifacts (e.g., ios/, android/, package.json native deps).
If you change a JS file, the hash remains the same (OTA allowed). If you add a native library, the hash changes (OTA blocked until new binary build).
Implementation in app.json:
{
"expo": {
"runtimeVersion": {
"policy": "fingerprint"
},
"updates": {
"url": "https://u.expo.dev/your-project-id"
}
}
}
However, complex CI pipelines often struggle with "magic" policies. If your DevOps workflow requires explicit versioning strings (e.g., matching a Git tag or a release branch), you need Method 2.
Method 2: Dynamic app.config.ts Strategy
For granular control, migrate from app.json to app.config.ts. This allows us to inject environment variables from the CI pipeline directly into the runtime version logic.
This approach allows you to tie the runtime version to a specific native build identifier managed by your release engineering process.
The Configuration (app.config.ts):
import { ExpoConfig, ConfigContext } from 'expo/config';
// Safe execution helper to ensure env vars exist
const getEnv = (key: string, required = false): string => {
const val = process.env[key];
if (required && !val) {
throw new Error(`Missing required environment variable: ${key}`);
}
return val || '';
};
export default ({ config }: ConfigContext): ExpoConfig => {
// 1. Default to standard SDK policy for local dev
let runtimeVersion: string | { policy: 'sdkVersion' | 'nativeVersion' | 'fingerprint' } = {
policy: 'sdkVersion'
};
// 2. In CI, we override this with a strict version specific to the native build
// commonly formatted as "1.0.0" or "build-145"
const ciRuntimeVersion = getEnv('EXPO_RUNTIME_VERSION');
if (ciRuntimeVersion) {
runtimeVersion = ciRuntimeVersion;
} else {
// Optional: Fallback to fingerprint if not in CI
runtimeVersion = { policy: 'fingerprint' };
}
return {
...config,
runtimeVersion,
updates: {
url: "https://u.expo.dev/YOUR_PROJECT_ID",
fallbackToCacheTimeout: 0
},
// Ensure you handle the extra config needed for EAS
extra: {
eas: {
projectId: "YOUR_PROJECT_ID"
}
}
};
};
Integrating with GitHub Actions
Now that the application accepts an injected runtime version, we must configure the CI pipeline to supply it. The goal is to ensure that eas build (native) and eas update (OTA) share the exact same source of truth.
The Workflow Strategy
We will use a composite action approach. We define a version number (or read it from package.json) and pass it to both the build and update commands via the EXPO_RUNTIME_VERSION environment variable.
Workflow File (.github/workflows/deploy.yml):
name: Production Deployment
on:
push:
branches:
- main
jobs:
update:
runs-on: ubuntu-latest
env:
# Define the strict version that ties Native and JS together
# In a real scenario, this might be extracted from package.json
# or a specific tag: e.g. ${{ github.ref_name }}
RUNTIME_VERSION: "2.1.0"
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Setup EAS
uses: expo/expo-github-action@v3
with:
token: ${{ secrets.EXPO_TOKEN }}
# CRITICAL STEP: Publish Update with Explicit Runtime Version
- name: Publish EAS Update
run: eas update --branch production --message "Commit: ${{ github.sha }}"
env:
# This injects the variable into app.config.ts
EXPO_RUNTIME_VERSION: ${{ env.RUNTIME_VERSION }}
# Optional: Verify the update target
- name: Verify Deployment
run: |
echo "Deployed update for Runtime Version: $RUNTIME_VERSION"
echo "Ensure your native builds were compiled with EXPO_RUNTIME_VERSION=$RUNTIME_VERSION"
Deep Dive: Why This Fix Works
This configuration solves the root cause by removing ambiguity.
When eas update runs, it reads app.config.ts. The script detects the EXPO_RUNTIME_VERSION environment variable and sets the runtimeVersion property in the manifest to "2.1.0" (from our YAML example).
When the native app (built previously with the same env var) checks for updates, it sends: expo-runtime-version: 2.1.0
The server sees the new update has a matching metadata tag of "2.1.0". The handshake succeeds, and the update downloads.
If you were to change RUNTIME_VERSION in the YAML to "2.2.0" because you added native code, but users still had the "2.1.0" app installed, the server would correctly reject the update for those users, preventing the crash.
Common Pitfalls and Edge Cases
1. The "Debug" Build Trap
Development builds (Debug configuration) often ignore OTA updates or handle them differently depending on the expo-dev-client setup. Always test OTA behavior on a Release build (Simulators are fine, but use Release scheme).
2. Cache Invalidation
Changing the logic in app.config.ts might be cached by the Expo CLI locally. When testing these changes locally, run npx expo config --type public to inspect the generated JSON and confirm your environment variables are being picked up correctly.
3. Android vs. iOS Divergence
Rarely, you might modify native Android code but not iOS. Using a single version string implies both platforms update simultaneously. If you need platform-specific runtime versions, modify app.config.ts to check process.env.EAS_BUILD_PLATFORM or similar context hints, though keeping them synchronized is generally safer for maintenance.
Conclusion
Resolving runtime version mismatches requires shifting from static configuration to dynamic, environment-aware policies. By implementing app.config.ts logic that consumes CI environment variables, you create a deterministic link between your native binaries and your JavaScript bundles. This prevents users from receiving incompatible updates and eliminates the "white screen of death" associated with missing native modules.