OTA Updates with EAS Update: Ship JavaScript Without App Store Review
Mobile13 min read

OTA Updates with EAS Update: Ship JavaScript Without App Store Review

A deep, practical guide to over-the-air updates in Expo apps with EAS Update — how expo-updates works, runtime versioning, channels and branches, staged rollouts, programmatic update checks, and the App Store rules that draw the line between what you can and cannot ship OTA.

Taha Kocal

Taha Kocal

Full-Stack & Mobile Developer

May 18, 2026
#Expo#EAS Update#React Native#OTA#Mobile#CI/CD#DevOps

EAS Update is Expo's hosted over-the-air (OTA) update service for the expo-updates library: it pushes new JavaScript bundles and assets directly to installed React Native apps, often within minutes, without going through App Store or Play Store review. OTA updates work because a React Native binary is a native shell plus a JavaScript bundle; EAS Update replaces that bundle while the native code stays untouched, so a one-line copy fix or a critical bug patch no longer waits days for review and user installs. The trade-off is a strict boundary: anything that changes native code — adding a native module, changing app icons or permissions, upgrading the Expo SDK — still requires a full store build. Compatibility is enforced through runtime versions, and delivery is controlled with channels, branches, staged rollouts, and rollbacks. This article covers the full production setup.

This power comes with a precise boundary. OTA updates change the JavaScript bundle and bundled assets your app runs, but they cannot change the native binary. Understanding exactly where that line sits — and how expo-updates, runtime versions, and channels enforce it — is the difference between a smooth release pipeline and a fleet of bricked installs. This article walks through the full mental model and the real configuration you need in production.

What Can OTA Updates Change, and What Can They Not?

A React Native app is two things fused together: a native host (the compiled iOS/Android binary containing the React Native runtime, all native modules, and platform APIs) and a JavaScript bundle (your app code and its JS dependencies) plus assets like images and fonts. When the app launches, the native host loads and executes the JavaScript bundle. OTA updates work by swapping that JavaScript bundle and its assets for a newer version — the native host stays exactly as it was when the binary was built and installed.

This gives you a clean rule of thumb. You CAN ship over the air:

  • Changes to your React/React Native component code, business logic, and hooks
  • JavaScript-only dependency updates that do not add native code
  • Bundled images, fonts, JSON, and other static assets referenced with require()
  • Styling, copy, layout, and configuration that lives in JS
  • Bug fixes and feature flags implemented entirely in JavaScript

You CANNOT ship over the air, because they require recompiling the native binary:

  • Adding, removing, or upgrading a library that contains native code (e.g. expo-camera, react-native-reanimated version bumps with native changes)
  • Changing native configuration: permissions, entitlements, app icons, splash screens, URL schemes, or anything in app.json/Info.plist/AndroidManifest that is baked at build time
  • Upgrading the Expo SDK or React Native version
  • Changing native code you wrote in a config plugin or in the ios/android directories
  • Anything that alters the app version/build number shown in the stores

The single most important mental model: OTA updates replace the JavaScript your existing binary runs. If a change needs a new binary, no amount of OTA cleverness will deliver it safely — you need a new build.

How Does expo-updates Work?

The expo-updates library is the client that lives inside your native binary. When you publish with EAS Update, the CLI bundles your JavaScript and assets, uploads them to Expo's CDN, and creates an "update manifest" — a JSON document describing the update: its ID, the assets it needs, the launch bundle, and critically the runtime version and channel it targets. The client fetches this manifest to decide whether a newer compatible update exists.

The download-on-launch lifecycle

By default, expo-updates checks for updates when the app launches. The flow, in order, is: the app cold-starts, expo-updates requests the manifest for its channel and runtime version, and if a new update is available it downloads the new bundle and assets in the background. The default behavior is that the newly downloaded update is applied on the NEXT launch — the current session keeps running the bundle it started with. This "download now, apply next launch" default avoids yanking the UI out from under an active user. You can override this to check and apply mid-session, which we cover later.

expo-updates keeps the most recent successfully-launched update cached on device, so if the network is unavailable the app still starts from the last good bundle. This is why an OTA rollback is not "delete the bad update" — the device already downloaded it. A rollback means publishing a newer update that the client will prefer.

Setting Up EAS Update

If you already use EAS Build, adding EAS Update is a few commands. First install the client library into your project — always use the Expo installer so you get the version matched to your SDK:

bash
# Install the expo-updates client into the project
npx expo install expo-updates

# Configure EAS Update: sets the update URL and runtimeVersion,
# wires channels into your build profiles
eas update:configure

The eas update:configure command edits app.json (or app.config.js) and eas.json for you. In app.json it adds the updates block, which tells the native binary where to fetch manifests and how to behave on launch:

json
{
  "expo": {
    "name": "MyApp",
    "slug": "my-app",
    "runtimeVersion": {
      "policy": "appVersion"
    },
    "updates": {
      "url": "https://u.expo.dev/your-project-id",
      "enabled": true,
      "checkAutomatically": "ON_LOAD",
      "fallbackToCacheTimeout": 0
    }
  }
}

A note on fallbackToCacheTimeout: it controls how long, in milliseconds, the app waits at startup for a new update to download before falling back to the cached bundle. Setting it to 0 (the common production choice) means the app always launches instantly from cache and applies any downloaded update on the next launch. A non-zero value blocks startup up to that timeout to try to launch the freshest bundle — useful only if you truly need immediate application and accept a slower cold start.

Runtime Versioning — The Compatibility Contract

The runtimeVersion is the most important and most misunderstood concept in OTA updates. It is a string that identifies the native capabilities of a binary. Every build stamps itself with a runtime version, and every published update is tagged with one too. The expo-updates client will ONLY download and apply an update whose runtime version exactly matches the runtime version of the installed binary. This is the safety mechanism that prevents a JavaScript bundle expecting a native module from being delivered to a binary that does not have it.

Think of it this way: the JavaScript bundle is a contract, and the native binary is the implementation. The runtime version is the contract's signature. If they do not match, the update is simply not offered to that device — which is far better than a crash.

Runtime version policies

Rather than hand-managing this string, you declare a policy in app.json and Expo derives the value for each build:

  • appVersion — the runtime version equals your app version (e.g. "1.4.0"). Simple and readable; bump the app version whenever you change native code and OTA updates flow to matching binaries.
  • nativeVersion — combines app version and native build number. More granular but changes on every native build.
  • fingerprint — Expo computes a hash of everything that affects the native layer (dependencies, config plugins, native directories). The runtime version changes automatically and only when the native surface actually changes. This is the most robust option and is recommended for most teams.
  • A hardcoded string — full manual control; you are responsible for changing it whenever native code changes.
json
{
  "expo": {
    "runtimeVersion": {
      "policy": "fingerprint"
    }
  }
}

The golden rule of runtime versioning: if you change anything native, the runtime version MUST change so old binaries do not receive incompatible JavaScript. The fingerprint policy enforces this automatically; every other policy relies on your discipline.

The failure mode to internalize: if you add a native dependency but forget to change the runtime version, your new JavaScript bundle — which imports that native module — gets delivered OTA to old binaries that lack it. The app crashes on launch for those users, and because the bad update is cached, they crash every time until you republish. Runtime versioning exists precisely to make this impossible when used correctly.

Channels and Branches

EAS Update separates two concerns that people often conflate. A branch is a named line of updates — a sequence of published bundles, like "production", "staging", or a feature branch. A channel is a pointer that binaries subscribe to; a build is tied to exactly one channel at build time, baked into the binary. The channel maps to a branch, and you can re-point that mapping without rebuilding.

This indirection is powerful. Your production binaries all subscribe to the "production" channel. Normally that channel points to the "production" branch. If you want to test a hotfix branch against real production binaries, you can temporarily point the production channel at a different branch — no rebuild needed. When done, point it back.

Mapping build profiles to channels

You declare which channel each build profile uses in eas.json. A binary built with the "preview" profile only ever receives updates published to the channel it is bound to:

json
{
  "cli": { "version": ">= 12.0.0" },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "channel": "development"
    },
    "preview": {
      "distribution": "internal",
      "channel": "preview"
    },
    "production": {
      "autoIncrement": true,
      "channel": "production"
    }
  },
  "submit": {
    "production": {}
  }
}

With this setup, internal testers on a preview build get updates from the preview channel, while store users on a production build get updates from the production channel. The two never cross — you can iterate rapidly on preview without any risk to production users.

How Do You Publish an Update?

Publishing is a single command. The --branch flag selects the branch (which a channel points to), and --message is your human-readable changelog entry, shown in the EAS dashboard:

bash
# Publish the current project state to the production branch
eas update --branch production --message "Fix checkout total rounding bug"

# Publish only for a single platform if needed
eas update --branch production --platform ios --message "iOS-only layout fix"

# Auto-derive branch/message from the current git branch and commit
eas update --auto

Under the hood this exports the JS bundle for both platforms, uploads changed assets (unchanged assets are deduplicated), computes the runtime version from your policy, and creates the manifest. Devices on the matching channel and runtime version will pick it up on their next update check. In CI, eas update --auto is convenient because it reads the branch and commit message from git, keeping your update history tied to source control.

Rollout Strategies: Staged Rollouts and Rollbacks

Pushing an update to 100% of users instantly is fast but risky. EAS Update supports gradual rollouts so you can expose an update to a percentage of traffic, watch your crash and error dashboards, and increase exposure only if healthy.

bash
# Publish, then roll out to 10% of the branch's traffic
eas update --branch production --message "New onboarding flow" --rollout-percentage 10

# Later, edit the rollout to widen exposure
eas update:edit --rollout-percentage 50

# Confident it's healthy? Complete the rollout to 100%
eas update:edit --rollout-percentage 100

If an update turns out to be broken, you have two recovery paths. The fastest is a republish: build or re-point to a known-good previous update so the client prefers it. EAS provides a dedicated rollback mechanism that publishes a special "rollback to embedded" or "rollback to a prior update" directive on the branch:

bash
# Roll the branch back so clients revert to the update embedded in the binary
eas update:rollback

# Or republish a specific prior update by its group ID to the branch
eas update:republish --group <update-group-id> --message "Revert to stable checkout"

Remember that a rollback is itself an OTA update: it only takes effect after each device performs its next update check and relaunch. There is inherent latency — this is exactly why you stage rollouts. Catching a bad update at 10% exposure means only a tenth of your users ever download it, and the rollback reaches them on their next launch.

How Do You Check and Apply Updates Programmatically?

The default launch-time check is enough for many apps, but you often want finer control: check for an update after a session has been running for a while, prompt the user, or force-apply a critical fix. The Updates module from expo-updates gives you the primitives. The three you will use most are checkForUpdateAsync (is there a new update?), fetchUpdateAsync (download it), and reloadAsync (restart the JS runtime with the new bundle).

typescript
import * as Updates from 'expo-updates';

export async function checkForAppUpdate(): Promise<boolean> {
  // Never run this in development — updates are disabled there
  if (__DEV__) {
    return false;
  }

  try {
    const result = await Updates.checkForUpdateAsync();

    if (result.isAvailable) {
      // Download the new bundle and assets to disk
      await Updates.fetchUpdateAsync();
      return true;
    }

    return false;
  } catch (error) {
    // Network failures here should be non-fatal — the app keeps
    // running on its current bundle.
    console.warn('Update check failed', error);
    return false;
  }
}

Once an update is fetched, you decide when to apply it. The respectful pattern is to ask the user rather than yanking the app out from under them mid-task, then call reloadAsync only on their confirmation:

tsx
import { useEffect } from 'react';
import { Alert, AppState } from 'react-native';
import * as Updates from 'expo-updates';

export function useOtaUpdates() {
  useEffect(() => {
    const subscription = AppState.addEventListener('change', async (state) => {
      // Re-check when the app returns to the foreground
      if (state !== 'active' || __DEV__) return;

      try {
        const { isAvailable } = await Updates.checkForUpdateAsync();
        if (!isAvailable) return;

        await Updates.fetchUpdateAsync();

        Alert.alert(
          'Update available',
          'A new version is ready. Restart now to apply it?',
          [
            { text: 'Later', style: 'cancel' },
            { text: 'Restart', onPress: () => Updates.reloadAsync() },
          ]
        );
      } catch (error) {
        console.warn('OTA update flow failed', error);
      }
    });

    return () => subscription.remove();
  }, []);
}

Expo also ships a useUpdates() hook that exposes reactive state — isUpdateAvailable, isUpdatePending, currentlyRunning, and error — which is cleaner than manually tracking flags for building update UI. Whatever pattern you choose, guard every path with __DEV__ checks, because updates are disabled in development and calling these APIs there is a no-op that can mask bugs.

The App Store Guideline Boundary

OTA updates are explicitly permitted by Apple, but within limits. App Store Review Guideline 3.3.1 (and the broader guidelines) allow you to download and execute JavaScript so long as the code does not change your app's primary purpose, introduce features inconsistent with what was approved, or circumvent the review process for what would otherwise require a new binary. Google Play has an analogous stance. In practice this means:

  • Bug fixes, UI tweaks, content updates, and iterating on already-approved features are clearly fine.
  • Do NOT use OTA to add a fundamentally new, unreviewed feature or to change the app's core purpose after approval.
  • Do NOT use OTA to bypass review for something that materially changes what users were shown in the store listing.
  • The executed code must be served for use only in the app and not downloaded to build a general-purpose interpreter for arbitrary code.

A useful test: would a reviewer, seeing your update, consider it a continuation of the approved app or a different product? Continuations are fine. New products need a new submission.

Pitfalls to Avoid

Runtime version mismatch

The most damaging mistake is changing native code without changing the runtime version, delivering JS that references a missing native module. Use the fingerprint policy so this can never happen silently, and treat "does this change require a new build?" as a mandatory checklist item before every OTA publish.

Native-dependency changes shipped as OTA

Bumping a library that includes native code — even a patch version — may change the native surface. Such a change needs a new build and, ideally, a new runtime version. Trying to ship it OTA either does nothing (runtime version changed, no matching binaries) or crashes (runtime version unchanged but native code differs).

Asset size and the download budget

Updates download over the network on launch. A bundle bloated with large new images or fonts means slow downloads, more failed updates on poor connections, and delayed application. Keep assets lean, lean on remote assets for large media rather than bundling them, and remember that only changed assets are re-downloaded — but a big new asset still costs every user their bandwidth once.

Forgetting that rollbacks have latency

Because clients only check on launch (by default), a rollback does not instantly heal a broken fleet. Staged rollouts are your primary defense; a fast rollback is your second. Do not rely on rollback alone to make risky 100% releases safe.

Embedded update and the first launch

Every build embeds the JS bundle it was compiled with. A freshly installed app runs that embedded bundle first, then checks for an OTA update. Make sure the JS you embed at build time is production-ready on its own — never assume "the OTA will fix it," because the very first session, and any offline session, runs the embedded bundle.

Key Takeaways

  • OTA updates via EAS Update replace the JavaScript bundle and assets your installed binary runs — they cannot change native code, permissions, SDK versions, or store metadata.
  • expo-updates fetches an update manifest on launch, downloads compatible updates in the background, and applies them on the next launch by default.
  • runtimeVersion is the compatibility contract: an update is only delivered to binaries with a matching runtime version. Prefer the fingerprint policy so it changes automatically when the native surface changes.
  • Branches are lines of updates; channels are pointers baked into binaries that map to branches. Map each build profile to a channel in eas.json to isolate preview from production.
  • Publish with eas update --branch <name> --message <msg>; use staged rollouts (--rollout-percentage) and rollback/republish to release safely.
  • Use checkForUpdateAsync, fetchUpdateAsync, and reloadAsync (or the useUpdates hook) for programmatic control, and always guard with __DEV__ and a respectful update UX.
  • Stay within App Store guidelines: ship fixes and iterate on approved features, never a new product or a change that bypasses review.
  • When in doubt, ask "does this need a new binary?" If the answer is yes, build and submit — do not force it over the air.

Share this article

Related Articles