Over-the-Air Updates
expo-updates swaps the JavaScript in an installed build, so JS fixes ship in minutes while native changes still need a build.
eas update --channel <channel> --environment <environment> --message "<description>"Your app is two layers: a native binary from the store, and a JavaScript bundle with assets inside it. expo-updates can replace the second layer on a device that already has the first. A typo fix ships in minutes, with no review.
Channels and branches
A build is stamped with a channel from its build profile. eas update --channel production --environment production --message "fix date parsing" publishes to the branch that channel points at, so production builds get it and preview builds do not. From SDK 55 on, --environment is required: it tells EAS which set of environment variables to bundle the update with.
Runtime versions
An update only reaches builds whose runtimeVersion matches. That is the safety rule: JavaScript calling a native module the binary does not contain would crash. The fingerprint policy computes the value from everything that affects the native side, so adding expo-camera changes it and the old builds correctly stop receiving your new JavaScript.
What cannot ship this way
Any native change: a new native module, a config value that becomes native code, an SDK or React Native upgrade. Those need a build and a store release. Store policy also forbids using updates to change what the app fundamentally does.
Timing and rollback
By default the app checks on launch and runs the new bundle on the next launch, so you will not see your fix immediately. eas update:rollback walks you through pointing clients back to an earlier update, or to the one embedded in the binary.
Example
// UpdateBanner.tsx - offer the new bundle instead of waiting for a relaunch
import * as Updates from "expo-updates";
import { useState } from "react";
import { Button, StyleSheet, Text, View } from "react-native";
export default function UpdateBanner() {
// useUpdates reflects the state expo-updates already tracks natively.
const { isUpdateAvailable, isUpdatePending, isDownloading } = Updates.useUpdates();
const [error, setError] = useState<string | null>(null);
async function install() {
try {
// Skip the download when the bundle is already on disk and waiting.
if (!isUpdatePending) {
await Updates.fetchUpdateAsync();
}
await Updates.reloadAsync(); // restarts the app on the new bundle
} catch (e) {
setError(e instanceof Error ? e.message : "Update failed");
}
}
// The early return comes after every hook, so hook order never changes.
if (!isUpdateAvailable && !isUpdatePending) {
return null;
}
return (
<View style={styles.bar}>
<Text style={styles.text}>{error ?? "A new version is ready"}</Text>
<Button title={isDownloading ? "Downloading" : "Restart"} onPress={install} />
</View>
);
}
const styles = StyleSheet.create({
bar: {
flexDirection: "row", // flexDirection defaults to column here, unlike CSS
alignItems: "center",
justifyContent: "space-between",
padding: 12,
backgroundColor: "#1E3A8A",
},
text: { color: "#FFFFFF", flexShrink: 1 },
});When to use it
- Ship a copy fix or a crash fix without a store review.
- Send a preview-channel update to testers between builds.
- Roll a suspect update back to the bundle embedded in the store build.
- Decide whether a change needs a new build by watching whether the fingerprint runtime version moved.
More examples
Update configuration in app.config.ts
Run eas update:configure rather than writing this by hand; it fills in the url and the project id. Raise fallbackToCacheTimeout only if you truly want the user to wait at the splash screen for a download on a slow connection, which is rarely the right trade.
// app.config.ts - updates settings, written mostly by eas update:configure
import type { ConfigContext, ExpoConfig } from "expo/config";
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: "Trailhead",
slug: "trailhead",
version: "1.2.0",
updates: {
url: "https://u.expo.dev/4f1a2b3c-0000-0000-0000-000000000000",
// ON_LOAD is the default: check at launch, apply on the next launch.
checkAutomatically: "ON_LOAD",
// 0 is the default: never block the splash screen waiting for a download.
fallbackToCacheTimeout: 0,
},
// fingerprint changes whenever anything affecting native code changes, which
// is what stops a JS update reaching a build that cannot run it.
runtimeVersion: { policy: "fingerprint" },
});Publishing, listing and rolling back
The listing commands are the ones to learn first: a published update that nobody receives is almost always a runtime version mismatch, and update:list shows the runtime version each update was built for next to the one your builds report.
# One-time setup; adds updates.url and runtimeVersion to the config
eas update:configure
# Publish the current JS to the production channel. --environment is required
# from SDK 55 on: it selects the EAS environment variables to bundle with.
eas update --channel production --environment production \
--message "fix crash on empty trail list"
# Risk-managed: five percent of devices first
eas update --channel production --environment production --rollout-percentage 5 \
--message "new map renderer"
# What is live, and on which runtime versions
eas channel:list
eas update:list --branch production
# Interactive: back to a previous update, or to the embedded bundle
eas update:rollbackA manual check, away from launch
A long-lived app may not be launched cold for days, so the default launch-time check never fires. This downloads quietly on each foreground and lets the next relaunch pick it up, with no banner. checkForUpdateAsync returns isAvailable, and may instead return isRollBackToEmbedded if you have published a rollback.
// useUpdateOnForeground.ts - check when the app comes back to the foreground
import * as Updates from "expo-updates";
import { useEffect } from "react";
import { AppState } from "react-native";
export function useUpdateOnForeground() {
useEffect(() => {
const sub = AppState.addEventListener("change", async (state) => {
// Updates.isEnabled is false in development builds; checking there throws.
if (state !== "active" || !Updates.isEnabled) {
return;
}
const result = await Updates.checkForUpdateAsync();
if (result.isAvailable) {
await Updates.fetchUpdateAsync(); // applied on the next launch
}
});
return () => sub.remove();
}, []);
}
Discussion