# Over the air (OTA) with Expo updates
AI generated
Updating a React Native app “over the air” (OTA) means you ship JavaScript/asset updates directly to users’ devices without requiring them to download a new version from the App Store or Google Play. This is super useful for fixing bugs, updating UI, or tweaking business logic — but you can’t update anything in native code (new permissions, SDKs, native modules, etc.) this way.
# Utils commands
Install
npx expo install expo-updates
Publish an update
npx expo publish
# Config
app.config.js
export default {
expo: {
name: 'Your app',
slug: 'your-app',
version: '1.0.2',
updates: {
url: process.env.EXPO_UPDATE_URL || '',
assetPatternsToBeBundled: ['assets/**/**'],
checkAutomatically: 'NEVER',
enabled: true,
fallbackToCacheTimeout: 0,
requestHeaders: {
'x-api-key': process.env.EXPO_UPDATE_SECRET,
},
codeSigningMetadata: {
keyid: 'main',
alg: 'rsa-v1_5-sha256',
},
}
//...
}
}
assetPatternsToBeBundled
- Glob pattern of files that should be bundled with the app binary.
- Example:
['assets/**/**']
→ means include all files insideassets/
(e.g., images, fonts, icons). - Ensures users have these assets even before the app fetches OTA updates.
checkAutomatically
- Controls when the app checks for updates.
- Possible values:
- "ON_LOAD" → check immediately on app launch.
- "ON_ERROR_RECOVERY" → check only after recovering from an error.
- "NEVER" → don’t check automatically (you must call update functions manually).
- Here it’s set to "NEVER", meaning you’ll have to trigger OTA update checks in your code (using
Updates.fetchUpdateAsync()
).
fallbackToCacheTimeout
- How long (in ms) the app waits for a new update before falling back to the cached bundle.
0
means use the cached version immediately and load updates in the background (safer for UX because the app launches instantly).
requestHeaders
- Custom headers sent with OTA update requests
- This is often used when your OTA server is private/protected and needs authentication (like an API key).
codeSigningMetadata
- Metadata for update code signing (security feature).
- Ensures OTA updates are cryptographically verified before being applied (prevents tampering).
- Fields:
- keyid:
'main'
→ identifies which signing key to use. - alg:
'rsa-v1_5-sha256'
→ specifies the signing algorithm.
- keyid:
# Self-hosted OTA system
# High-level architecture
- Clients (iOS/Android): your RN app using expo-updates with:
- updates.url → your Update API base URL
- requestHeaders → API key, etc.
- codeSigningMetadata → key id + algorithm
- Update API (Node/Express or similar):
- Authenticates requests
- Selects the right update by runtimeVersion + platform
- Returns a signed manifest (and 304 when nothing new)
- Asset storage (S3/GCS/Azure Blob + CDN):
- Stores index.bundle (launch asset) + images/fonts/maps
- Served over HTTPS with immutable caching
- CI/CD pipeline:
- Builds production JS bundles + assets
- Generates an Expo manifest (JSON)
- Signs the manifest with your private key
- Uploads assets to storage/CDN
- Publishes a record to your Update DB
- Database (Postgres/SQLite):
- Tracks updates: id, createdAt, runtimeVersion, platform, manifest URL, assets
# Data model (minimal)
updates
- id (uuid)
- platform (ios | android)
- runtime_version (string; must match app’s runtimeVersion)
- channel (e.g., production, staging) — optional but useful
- commit_time (timestamp)
- manifest_url (string) — or store the JSON inline
- launch_asset_url (string)
- assets (jsonb): array of {fileName, url, contentType}
- rollout (int 0–100) — optional for phased releases
- is_disabled (bool) — for fast rollback
# Expo manifest shape
Expo manifest shape is what your API returns.
expo-updates
expects a manifest describing the launch asset + other assets. A typical response for GET /update
:
{
"id": "2b1f9c0a-0a4e-4bda-a2bc-3bd5d7a6f0b8",
"createdAt": "2025-08-30T06:40:00.000Z",
"runtimeVersion": "1.0.0",
"launchAsset": {
"key": "bundle-ios-2b1f9c0a",
"contentType": "application/javascript",
"url": "https://cdn.example.com/updates/2b1f9c0a/index.ios.bundle"
},
"assets": [
{
"key": "asset_82f6a.png",
"contentType": "image/png",
"url": "https://cdn.example.com/updates/2b1f9c0a/assets/asset_82f6a.png"
},
{
"key": "fonts_inter_regular",
"contentType": "font/ttf",
"url": "https://cdn.example.com/updates/2b1f9c0a/assets/Inter-Regular.ttf"
}
],
"metadata": {},
"extra": {
"channel": "production",
"branchName": "main",
"commit": "cafe123"
}
}
Required headers your server should set
expo-protocol-version: 1
expo-server-defined-headers: {}
(optional)expo-manifest-signature: <base64 signature>
(if you enable code signing)cache-control: no-cache
(for the manifest route)
The
expo-manifest-signature
is the RSA signature of the manifest body (string). The client validates it using the public key embedded during build (via expo-updates Code Signing).
# Update API (Node/Express)
// app.ts
import express from "express";
import crypto from "crypto";
const app = express();
app.use(express.json({ limit: "2mb" }));
// Load your private key for signing manifests
const PRIVATE_KEY_PEM = process.env.MANIFEST_PRIVATE_KEY_PEM!;
const API_KEY = process.env.EXPO_UPDATE_SECRET!;
function signManifest(body: string) {
const signer = crypto.createSign("RSA-SHA256");
signer.update(body);
signer.end();
return signer.sign(PRIVATE_KEY_PEM).toString("base64");
}
app.get("/update", async (req, res) => {
// --- Auth
if (req.header("x-api-key") !== API_KEY) {
return res.status(401).send("Unauthorized");
}
// --- Identify client
const platform = (req.header("expo-platform") || "").toLowerCase(); // "ios" | "android"
const runtimeVersion = req.header("expo-runtime-version"); // must match your app
const channel = req.header("expo-release-channel") || "production"; // optional
if (!platform || !runtimeVersion) {
return res.status(400).send("Missing platform/runtimeVersion");
}
// --- Lookup the newest enabled update for this (platform, runtimeVersion, channel)
const update = await findLatestUpdate({ platform, runtimeVersion, channel });
if (!update) {
// No update — respond 204; client keeps current bundle
return res.status(204).end();
}
const manifest = {
id: update.id,
createdAt: update.commit_time.toISOString(),
runtimeVersion: update.runtime_version,
launchAsset: {
key: update.launch_key,
contentType: "application/javascript",
url: update.launch_asset_url
},
assets: update.assets,
metadata: update.metadata || {},
extra: { channel, commit: update.commit }
};
const body = JSON.stringify(manifest);
const signature = signManifest(body);
res.set("expo-protocol-version", "1");
res.set("expo-manifest-signature", signature);
res.set("cache-control", "no-cache");
return res.type("application/json").send(body);
});
app.get("/healthz", (_, res) => res.send("ok"));
app.listen(process.env.PORT || 8080);
# Building & packaging updates in CI
# iOS
npx expo export --platform ios --output-dir dist/ios
# Android
npx expo export --platform android --output-dir dist/android
This produces:
dist/<platform>/index.bundle
(launch asset)dist/<platform>/assets/*
(images/fonts)
# Code signing
- Generate an RSA keypair:
- Private key (kept in CI or your Update API container)
- Public key is embedded in the app at build time
- Your
expo-updates
app.json - The server signs the manifest (RSA-SHA256 → base64) and returns it in
expo-manifest-signature
. - The client verifies against the shipped public key, rejecting tampered updates.
# Client logic
import * as Updates from "expo-updates";
export async function checkAndApplyOTA() {
try {
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
await Updates.fetchUpdateAsync();
// You can choose to reload now or later:
await Updates.reloadAsync();
}
} catch (e) {
// log to your telemetry
}
}
Good places to call this:
- On cold start after splash screen (don’t block first paint)
- On app resume if > X hours since last check
- Expose a “Check for updates” button in Settings
# Caching & CDN
- Manifest: Cache-Control: no-cache (always revalidate)
- Assets: Cache-Control: public, max-age=31536000, immutable
- Use content-addressed file names (hashes) to get perfect CDN caching:
- e.g., asset_82f6a.png where 82f6a is a content hash
# Channels, rollouts, and targeting (nice-to-have)
- Channels: production, staging, QA — selected via request header or query
- Rollout: return the update only to X% of devices (e.g., by hashing
installationId
) - Targeting: filter by
expo-app-build-id
,expo-updates-environment
, locale, etc.
# Rollback strategy
- Add an
“Emergency disable”
flag on an update row; the API stops serving it immediately. - Keep N previous good updates; allow quick pinning to a known stable update id.
- Log crash-free rate by update id to detect regressions.
# Observability
- Log each /update request with: ip, platform, runtimeVersion, channel, servedUpdateId
- Metrics: requests/min, 204 rate (no update), latency, signature errors
- Track install success client-side (postback to your API)
# Security checklist
- HTTPS everywhere
- API key (or signed JWT) in requestHeaders
- Code signing required (reject unsigned manifests)
- Lock down your object storage to CDN origin access only
- Rotate keys on a schedule; support multiple keyids in the app if rotating
# CI/CD example (GitHub Actions sketch)
name: OTA Publish
on:
workflow_dispatch:
push:
branches: [ main ]
jobs:
build-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- name: Build bundles
run: |
npx react-native bundle --platform ios --dev false --entry-file index.js \
--bundle-output dist/ios/index.ios.bundle --assets-dest dist/ios/assets
npx react-native bundle --platform android --dev false --entry-file index.js \
--bundle-output dist/android/index.android.bundle --assets-dest dist/android/assets
- name: Upload to S3
run: node scripts/upload-to-s3.mjs
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET }}
S3_BUCKET: my-ota-bucket
- name: Publish manifest record
run: node scripts/publish-manifest.mjs
env:
UPDATE_API_URL: https://updates.example.com/admin/publish
ADMIN_TOKEN: ${{ secrets.UPDATE_ADMIN_TOKEN }}