Skip to content
OTFotf
All posts

EAS Update rollback plan keeps one bad bundle from stranding your users

D
DaveAuthor
7 min read
EAS Update rollback plan keeps one bad bundle from stranding your users

Shipping over-the-air updates feels safe until a bad JavaScript bundle reaches every installed device at once. Unlike an app store release you can halt, a broken OTA bundle keeps installing itself on every app launch until you replace it. Builders working with AI coding agents hit this harder, because an agent can generate a plausible-looking update, pass local checks, and publish it in one confident motion without ever testing the rollback path.

A rollback plan is not a nice-to-have for OTA updates. It is the thing that turns a bad deploy from a multi-hour outage into a ten-minute incident. This post lays out a practical plan: pinning runtime versions, separating update branches, adding a client-side kill switch, testing the real update path, and running a fixed rollback procedure when things go wrong.

Pin runtime versions before you ship anything

Every EAS Update bundle is bound to a runtime version, which describes the native code it is compatible with. If your runtime version drifts between builds, an update published for one native build can land on another and crash on launch. That failure mode is especially cruel because the app may crash before it can fetch the next, fixed update.

Set an explicit runtime version policy in your app config and never let it float silently:

{
  "expo": {
    "runtimeVersion": {
      "policy": "appVersion"
    },
    "updates": {
      "url": "https://u.expo.dev/your-project-id",
      "fallbackToCacheTimeout": 30000
    }
  }
}

The appVersion policy ties the runtime to your app version, so each store release starts a clean update lineage. When an AI agent bumps versions or edits app config, make it verify the runtime policy as part of the change — the agent acceptance checklist pattern of proving a change before merge applies directly here. A version bump without a runtime check is how incompatible bundles get published.

One more rule: native changes (new native modules, SDK upgrades, permission changes) always require a fresh store build, never an OTA update. Write that rule into your repo conventions so agents and humans alike cannot ship native code through the JavaScript lane.

Keep update branches separate from development builds

EAS Update uses branches and channels to decide which bundle a given build receives. The failure pattern to avoid is a single branch where development experiments, staging checks, and production releases all mix together. One careless publish and your test bundle is live on every user device.

A minimal branch setup that works:

# Preview builds point at staging, production builds at production
eas branch:create staging
eas branch:create production
eas channel:create staging
eas channel:create production
eas channel:edit staging --branch staging
eas channel:edit production --branch production

Publish to staging first, verify on a staging build, then promote the exact same update to production:

eas update --branch staging --message "fix checkout totals rounding"
# verify on a staging install, then promote the same update id
eas update:republish --group <update-group-id> --branch production

Republishing the same update group matters. It means production receives the identical bundle you tested, not a rebuild that might differ. Ask your agent to output the update group id after every publish and log it with the change description, so the audit trail from code change to live bundle is unbroken.

One codebase. iOS, Android, and web.

The Fitness Kit ships with auth, a database, and a backend already connected — no setup. Live demo at fitness-preview.otf-kit.dev.

See the live demo

Add a client-side kill switch for bad bundles

Even with branches and staging checks, some bugs only appear in production. A client-side kill switch lets the app itself refuse a broken bundle or fall back to safe behavior without waiting for a store review cycle.

The simplest effective version is a remote config flag checked at startup. Host the policy endpoint on your own backend alongside your other app config, and read its URL from environment config so staging and production never share one:

import { useEffect, useState } from 'react';
import { View, Text } from 'react-native';
import { UPDATE_POLICY_URL } from './config';

type UpdatePolicy = {
  minSafeBundle: string;
  forceFallback: boolean;
};

async function fetchUpdatePolicy(): Promise<UpdatePolicy> {
  const res = await fetch(UPDATE_POLICY_URL);
  if (!res.ok) {
    throw new Error(`Policy fetch failed: ${res.status}`);
  }
  return res.json();
}

export function UpdateGate({ bundleId, children }: { bundleId: string; children: React.ReactNode }) {
  const [blocked, setBlocked] = useState(false);

  useEffect(() => {
    fetchUpdatePolicy()
      .then((policy) => {
        if (policy.forceFallback || bundleId < policy.minSafeBundle) {
          setBlocked(true);
        }
      })
      .catch(() => {
        // Fail open on network errors: a policy outage
        // must not brick the app on top of everything else.
      });
  }, [bundleId]);

  if (blocked) {
    return (
      <View>
        <Text>Please update the app from the store to continue.</Text>
      </View>
    );
  }
  return <>{children}</>;
}

Store the policy in Supabase or your existing backend so flipping it is a row update, not a deploy. Note the fail-open catch: if the policy endpoint is down, the app continues normally. A kill switch that bricks the app when your backend hiccups is worse than no kill switch. And the fallbackToCacheTimeout setting in app config controls how long the app waits for an update before launching the cached bundle — set it deliberately rather than leaving the default.

Test the update path the way production uses it

Most OTA testing is really just testing the new code, not the update mechanism. The bugs that strand users live in the mechanism: migration between bundle versions, cached state from the old bundle, assets that changed shape.

A pre-publish check worth running every time:

  1. Install the current production build on a real device.
  2. Create realistic local state: logged-in session, cached queries, half-finished forms.
  3. Publish the candidate update to the staging branch.
  4. Launch the staging build and confirm the update applies cleanly over the old state.
  5. Force-quit mid-download once and relaunch, confirming the app falls back to the cached bundle instead of hanging.

Step 5 is the one teams skip, and it is the one that catches fallback misconfigurations. If your agent workflow generates the update, have it generate this checklist result too — not a claim that it tested, but the device, build id, and update group id it tested with. Concrete identifiers beat vague assurances, the same lesson behind keeping an agent-readable repository structure where every artifact is traceable.

For data-layer changes, test against production-shaped data. A migration that works on an empty local database and fails on a user with two years of cached rows is a classic OTA incident. Seed staging with realistic volume before signing off.

Roll back in minutes with a fixed procedure

When a bad bundle is live, you need a procedure the on-call person can run half-asleep. Decide it now, write it down, and keep it short:

  1. Confirm the bad update group id from your publish log.
  2. Republish the last known-good update group to the production branch.
  3. If the bad bundle crashes before it can check for updates, flip the kill switch flag so affected clients show the fallback screen.
  4. Verify on a fresh install and on an affected device before declaring recovery.
  5. Write the incident note: what shipped, why staging missed it, what check gets added.
# Roll production back to the last known-good update group
eas update:republish --group <last-good-group-id> --branch production

The republish completes in minutes, and clients pick up the corrected bundle on next launch or restart. The step teams forget is number 4: verifying on an actually affected device. A fresh install pulls the fixed bundle directly and tells you nothing about whether a device holding the broken bundle recovers. Keep one test device in the broken state until recovery is confirmed on it.

After recovery, do the unglamorous follow-up: pin the staging gate that should have caught it. Every OTA incident should add exactly one check to the pre-publish list. That list stays short because incidents are rare when the earlier sections of this post are in place — and it stays honest because each entry traces to a real failure.

Sources

react-nativecross-platformagents
OTF Fitness Kit

Stop wiring. Start shipping.

  • Login, database, and backend already connected — nothing to set up
  • iOS + Android + web from one codebase
  • AI configs pre-tuned + 40+ tested prompts included