BUILT INTO EVERY GOODSPEED APP
EAS Build + OTA Update Channels
eas.json ships pre-configured profiles for preview, staging, and production EAS Update channels with a runtime version policy, so teams can push over-the-air JS fixes to production users in a single iteration without a new App Store review.
- Tier: Core
- Status: Static
- Always enabled
WHY IT MATTERS
Shipping a mobile app used to mean waiting days for an App Store or Google Play review every time you fixed a bug or tweaked copy. For teams running fast feedback loops, that delay is expensive. EAS Build and OTA Update channels separate native builds from JavaScript updates. The gas-template ships eas.json with four pre-wired profiles: a development build with the Expo dev client, a preview build distributed internally on the preview channel, a staging build on the staging channel for stakeholder sign-off, and a production build with autoIncrement on the production channel. Each non-development profile maps directly to a named EAS Update channel in app.config.js, so eas update --channel production publishes a JS-only fix to every production device in a single iteration, no store review required. The runtimeVersion policy is set to appVersion, which means an OTA update is delivered only to binaries that share the same native version, preventing the class of crash where a JS bundle expects a native module that has not yet shipped.
The template wires OTA update checking into the root layout so the first thing the app does after the splash screen is ask expo-updates whether a newer bundle is available. If one exists, it is fetched and applied before the user sees a single screen. More importantly, the template ships guardLaunch() in lib/app-update.ts: a crash-on-launch detection layer built on an AsyncStorage sentinel. On every cold start the app writes a timestamp under a known key. If the app renders successfully four seconds later, markLaunchHealthy() clears the sentinel. If the sentinel is still set on the next launch, the previous boot crashed before becoming healthy. When that crash happened on an OTA bundle (not the embedded binary), guardLaunch() reports the event to Sentry, captures it in PostHog as ota_rolled_back, and immediately tries to fetch a newer (presumably fixed) update. If no fix is available yet, the app continues on the current bundle and the operator can ship eas update --rollback. The embedded binary is always the safety net.
HOW IT IS WIRED
Real code from the GAS template
The code below is drawn from lib/app-update.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.
// lib/app-update.ts (gas-template) -- crash guard + OTA update apply
// Called in app/_layout.tsx useEffect before first render.
const PENDING_LAUNCH_KEY = '@gas:pending_launch';
/** Detects crash-on-launch on a prior OTA bundle. Reports + tries recovery. */
export async function guardLaunch(): Promise<boolean> {
if (!Updates.isEnabled) return false;
const pending = await AsyncStorage.getItem(PENDING_LAUNCH_KEY);
if (pending && !Updates.isEmbeddedLaunch) {
captureException(new Error('OTA crash-on-launch detected'), {
component: 'app-update', updateId: Updates.updateId ?? 'unknown',
});
await AsyncStorage.removeItem(PENDING_LAUNCH_KEY);
const { available } = await checkForUpdate();
if (available && (await fetchUpdate())) {
await Updates.reloadAsync();
return true; // reload imminent -- caller must not render
}
}
await AsyncStorage.setItem(PENDING_LAUNCH_KEY, String(Date.now()));
return false;
}
/** Call ~4s after UI renders. Clears the pending-launch sentinel. */
export async function markLaunchHealthy(): Promise<void> {
await AsyncStorage.removeItem(PENDING_LAUNCH_KEY);
}Source: goodspeed-apps/gas-template → lib/app-update.ts
HONEST LIMITS
When EAS Build + OTA Update Channels is the wrong choice
OTA updates are limited to JavaScript and asset changes. Adding a new native module, upgrading the Expo SDK to a version that ships new native code, installing a new config plugin, or changing iOS entitlements all require a full EAS build and a new store submission. Teams that modify native code frequently, such as apps integrating custom Swift or Kotlin SDKs, will trigger full builds on most release cycles and see little benefit from OTA channels beyond the convenience of a consistent build configuration. If every release touches native code, the OTA channel infrastructure is not actively harmful, but its primary value of reducing iteration time on JS-only fixes will rarely materialize. Regulatory environments that treat each App Store submission as an approval step should not use OTA updates to bypass that review. FDA-cleared medical device software, financial apps in certain jurisdictions, and apps whose terms of distribution require explicit store approval for every functional change must treat OTA updates as off-limits for feature changes. The runtimeVersion: appVersion policy prevents mismatched native/JS versions, but it does not prevent distributing functionality that bypasses regulatory review. For those apps, disable expo-updates entirely by setting updates.enabled: false in app.config.js, and treat every code change as a full binary release.
Tier: Core · Static
Evaluate your use case
Check whether eas build + ota update channels aligns with your target audience, platform constraints, and regulatory environment before enabling it.
Audit the config
This feature is always enabled. If you need to disable it, remove the corresponding template files after generation.
Seek alternatives
If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.
APPS USING THIS FEATURE
Every generated Goodspeed app includes eas build + ota update channels. Browse the ideas catalog to see apps across all categories that ship with this feature wired in.
CAPABILITIES
EAS Build + OTA Update Channels capability breakdown
Concrete dimensions of what the built-in eas build + ota update channels implementation covers. These reflect the actual template code, not a marketing summary.
| Item | Description | Strength |
|---|---|---|
| Build profiles | eas.json ships four profiles: development (dev client), preview (internal + preview channel), staging (internal + staging channel), and production (autoIncrement + production channel). | 4 pre-configured |
| OTA channels | Each non-dev profile maps to a named EAS Update channel. Publishing to the channel delivers a JS-only fix to production users without a store review. | preview / staging / production |
| Runtime version policy | app.config.js sets runtimeVersion.policy = appVersion so OTA updates are delivered only to binaries sharing the same native version, preventing JS/native mismatches. | appVersion policy |
| Crash-on-launch guard | guardLaunch() detects a crash on a prior OTA bundle via AsyncStorage sentinel. On detection it reports to Sentry, tries a newer update, and reloads before the first frame renders. | lib/app-update.ts |
| Fallback strategy | checkAutomatically ON_LOAD + fallbackToCacheTimeout 0 loads the embedded bundle immediately on any download failure. Operators can run eas update --rollback for a bad OTA. | ON_LOAD + immediate fallback |
COMMON QUESTIONS
Can I push OTA updates to only a subset of users (for example, beta testers)?
Yes, but it requires a deliberate channel strategy rather than a single production channel. The pattern is to give beta testers a binary built against a beta channel (eas build --profile preview distributes to internal testers via TestFlight or Internal App Sharing, both pointing at the preview channel). Shipping eas update --channel preview targets only those beta binaries. Production users on binaries built against the production channel never receive updates published to the preview channel. You can also use EAS Update rollout percentages to gradually expand an OTA to a percentage of production devices before going to 100%. The runtimeVersion policy ensures that a production OTA is never accidentally applied to an older binary that lacks a native dependency the new JS bundle expects.
What exactly counts as a JS-only change that is safe to push via OTA?
Any change that touches only the JavaScript bundle or bundled assets is safe: edited screens, new components, updated business logic, changed copy, updated images or fonts, new routes, modified API calls, and configuration changes that do not require a rebuild. What is not safe: adding a new Expo SDK package that ships native code (expo install adds to package.json but the native binaries are not present in an existing binary), adding a config plugin, changing Info.plist or AndroidManifest entries, adding new native modules via bare workflow, or changing Expo SDK major versions. A rough mental model: if the change would require running npx expo prebuild or eas build to take effect, it is not an OTA-safe change.
What happens if a bad OTA update crashes the app on launch?
The crash-on-launch guard in lib/app-update.ts handles this automatically. guardLaunch() writes a timestamp sentinel to AsyncStorage before the UI renders. If the app loads successfully, markLaunchHealthy() clears the sentinel four seconds later. If the app crashed before markLaunchHealthy() ran, the sentinel is still set on the next launch. When guardLaunch() detects the sentinel on an OTA bundle (Updates.isEmbeddedLaunch is false), it reports a crash-on-launch event to Sentry and PostHog, clears the sentinel, and tries to fetch a newer update. If the operator has already shipped a fix via eas update --channel production, the guard fetches and applies it before the user sees a crash loop. If no fix is available, the app runs on the current bundle and the operator is alerted to ship eas update --rollback.
Do OTA updates work on the iOS simulator and Android emulator?
OTA updates are enabled in all EAS build profiles that set updates.enabled: true in app.config.js. The template sets this globally. On a simulator or emulator running a preview-sim build, expo-updates will attempt to check for updates on the preview channel. In practice, most simulator testing uses the Expo dev client (eas build --profile development), which runs against Metro bundler directly and bypasses expo-updates entirely. OTA updates are most relevant for builds distributed to real devices via TestFlight, Internal App Sharing, or production App Store and Google Play. For local development, the dev client with Metro hot reload is faster and does not require publishing an update.
GET IT BUILT INTO YOUR APP