Skip to main content
Technology & EngineeringReact Native381 lines

Eas Build Ota Updates

Deploying React Native apps with EAS Build, app store submission, and OTA updates via EAS Update

Quick Summary34 lines
You are an expert in deployment for building cross-platform mobile apps with React Native.

## Key Points

- `"appVersion"`: Uses the `version` field. Bump it when native code changes.
- `"nativeVersion"`: Uses `ios.buildNumber` / `android.versionCode`. Most granular.
- `"fingerprint"`: Automatically hashes native dependencies. Best for Expo managed workflow.
- Use the `fingerprint` runtime version policy in Expo managed projects. It automatically detects native changes and prevents incompatible OTA updates.
- Always test OTA updates on the `preview` channel before pushing to `production`.
- Use `eas secret` for API keys and credentials instead of `.env` files in CI. Secrets are injected at build time and never appear in logs.
- Set `"autoIncrement": true` in eas.json and use `"appVersionSource": "remote"` so EAS manages build numbers. This avoids conflicts in team environments.
- Implement staged rollouts for production OTA updates, starting at 10-25% and monitoring crash rates before full rollout.
- Always include a meaningful `--message` with OTA updates for auditability.
- **Forgetting to bump runtime version**: When using `appVersion` policy, failing to bump the version after native changes causes OTA updates to target incompatible builds.
- **Large OTA bundles**: OTA updates download the entire JS bundle. Large bundles on slow connections lead to timeouts. Monitor bundle size with `npx expo export --dump-sourcemap`.
- **Not testing the update flow**: Test `Updates.checkForUpdateAsync()` and `reloadAsync()` in preview builds. The update flow is invisible in development mode.

## Quick Example

```bash
npm install -g eas-cli
eas login
eas init  # Links project to your Expo account
```

```
Channel         Branch          Purpose
──────────────  ──────────────  ────────────────────────
development     development     Dev builds, frequent updates
preview         preview         QA/tester builds
production      production      Live app store users
```
skilldb get react-native-skills/Eas Build Ota UpdatesFull skill: 381 lines
Paste into your CLAUDE.md or agent config

EAS Build and OTA Updates — React Native

You are an expert in deployment for building cross-platform mobile apps with React Native.

Core Philosophy

The deployment pipeline for a React Native app has two fundamentally different tracks: native builds (compiled binaries submitted to app stores) and JavaScript updates (OTA bundles delivered without store review). Understanding the boundary between these two tracks is the single most important deployment concept. A native build includes the compiled native code, native modules, and a JavaScript bundle. An OTA update replaces only the JavaScript bundle. If you add a native module or change native configuration, you need a new native build. If you fix a bug in JavaScript logic or update a component, an OTA update suffices.

The runtime version is the contract between native builds and OTA updates. It answers: "is this JavaScript bundle compatible with that native binary?" When native code changes, the runtime version must change, or OTA updates targeting the old runtime will crash on the new binary (or vice versa). The fingerprint policy automates this by hashing native dependencies, making it impossible to accidentally push an incompatible OTA update. For teams managing runtime versions manually, forgetting to bump the version after a native change is the most dangerous deployment mistake.

Staged rollouts are not optional for production OTA updates. Unlike native builds that go through app store review and have a natural delay, OTA updates take effect within minutes for all users on the matching runtime version. A bug in an OTA update reaches your entire user base almost instantly. Rolling out to 10% of users first, monitoring crash rates and error logs, and then increasing to 100% over hours or days is the only responsible way to deploy OTA updates to production.

Anti-Patterns

  • Pushing OTA updates that require native code changes: Adding a new native module and pushing a JavaScript update that imports it will crash for users who have the old native build. The fingerprint runtime policy prevents this automatically, but custom policies require manual vigilance with every deployment.

  • Skipping the preview channel and pushing directly to production: Every OTA update should be tested on a preview build before it reaches production users. The cost of a brief delay is trivial compared to the cost of a bad update reaching all users simultaneously.

  • Committing secrets to app.config.ts or eas.json: API keys, service account credentials, and tokens in source control are accessible to anyone who clones the repository. Use eas secret to store credentials that are injected at build time and never appear in logs or source code.

  • Not passing --non-interactive in CI/CD pipelines: EAS commands prompt for user input by default. In a CI environment, this causes the build to hang indefinitely waiting for input that will never come. Always pass --non-interactive in automated pipelines.

  • Deploying large OTA bundles without monitoring bundle size: OTA updates download the entire JavaScript bundle. A 20MB bundle on a slow mobile connection may timeout, leaving the user with a stale version. Monitor bundle size with npx expo export --dump-sourcemap and keep it lean by code-splitting and removing unused dependencies.

Overview

EAS (Expo Application Services) provides a complete deployment pipeline for React Native apps: EAS Build compiles native binaries in the cloud, EAS Submit sends them to app stores, and EAS Update delivers JavaScript bundle updates over the air (OTA) without a full app store review. This skill covers build profiles, CI/CD integration, update channels, and deployment strategies for production apps.

Core Concepts

EAS CLI Setup

npm install -g eas-cli
eas login
eas init  # Links project to your Expo account

eas.json Build Profiles

{
  "cli": {
    "version": ">= 12.0.0",
    "appVersionSource": "remote"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "ios": {
        "simulator": true
      },
      "env": {
        "APP_VARIANT": "development"
      },
      "channel": "development"
    },
    "preview": {
      "distribution": "internal",
      "env": {
        "APP_VARIANT": "preview"
      },
      "channel": "preview"
    },
    "production": {
      "autoIncrement": true,
      "env": {
        "APP_VARIANT": "production"
      },
      "channel": "production"
    }
  },
  "submit": {
    "production": {
      "ios": {
        "appleId": "you@example.com",
        "ascAppId": "1234567890",
        "appleTeamId": "ABCDE12345"
      },
      "android": {
        "serviceAccountKeyPath": "./google-service-account.json",
        "track": "internal"
      }
    }
  }
}

Build and Submit Commands

# Development build (with dev client)
eas build --profile development --platform ios

# Preview build (internal distribution for testers)
eas build --profile preview --platform all

# Production build
eas build --profile production --platform all

# Submit to stores after build
eas submit --profile production --platform ios
eas submit --profile production --platform android

# Build and submit in one command
eas build --profile production --platform all --auto-submit

EAS Update (OTA)

# Publish an update to the preview channel
eas update --channel preview --message "Fix login button alignment"

# Publish to production
eas update --channel production --message "Patch: handle null user gracefully"

# Publish targeting a specific branch
eas update --branch production --message "Hotfix: crash on empty list"

Implementation Patterns

Channel and Branch Strategy

Channel         Branch          Purpose
──────────────  ──────────────  ────────────────────────
development     development     Dev builds, frequent updates
preview         preview         QA/tester builds
production      production      Live app store users
# Map a channel to a branch
eas channel:edit production --branch production
eas channel:edit preview --branch preview

Runtime Version Policy

The runtime version determines which OTA updates are compatible with which native builds. If native code changes, the runtime version must change.

// app.json
{
  "expo": {
    "runtimeVersion": {
      "policy": "appVersion"
    }
  }
}

Policies:

  • "appVersion": Uses the version field. Bump it when native code changes.
  • "nativeVersion": Uses ios.buildNumber / android.versionCode. Most granular.
  • "fingerprint": Automatically hashes native dependencies. Best for Expo managed workflow.
{
  "expo": {
    "runtimeVersion": {
      "policy": "fingerprint"
    }
  }
}

CI/CD with GitHub Actions

# .github/workflows/eas-build.yml
name: EAS Build & Update

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  update:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: eas update --channel production --message "${{ github.event.head_commit.message }}"

  build:
    if: github.event_name == 'push' && contains(github.event.head_commit.message, '[build]')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: eas build --profile production --platform all --non-interactive --auto-submit

  preview:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: eas update --branch pr-${{ github.event.number }} --message "PR #${{ github.event.number }}"

Versioning Strategy

// app.config.ts
export default {
  version: "2.3.0", // User-facing version (semver)
  ios: {
    buildNumber: "1", // Auto-incremented by EAS
  },
  android: {
    versionCode: 1, // Auto-incremented by EAS
  },
  runtimeVersion: {
    policy: "fingerprint",
  },
};
# Let EAS manage build numbers
# In eas.json, set "autoIncrement": true
# EAS tracks the latest build number remotely

# Check current version
eas build:version:get --platform ios
eas build:version:get --platform android

# Manually set if needed
eas build:version:set --platform ios --build-number 42

Staged Rollout

# Create an update but only roll out to 10% of users
eas update --channel production --message "Test new feature" --rollout-percentage 10

# Check rollout status
eas update:list --channel production

# Increase rollout
eas channel:rollout production --percent 50

# Full rollout
eas channel:rollout production --percent 100

Environment Secrets

# Set secrets for EAS Build (not committed to source)
eas secret:create --scope project --name SENTRY_DSN --value "https://..."
eas secret:create --scope project --name API_KEY --value "sk_live_..."

# List secrets
eas secret:list

# Access in app.config.ts
export default {
  extra: {
    sentryDsn: process.env.SENTRY_DSN,
  },
};

Pre-Install and Post-Install Hooks

// eas.json — custom build steps
{
  "build": {
    "production": {
      "ios": {
        "image": "latest",
        "cocoapods": "1.15.2"
      },
      "android": {
        "image": "latest",
        "ndk": "25.1.8937393"
      }
    }
  }
}

Monitoring Updates

// Check for updates on app launch
import * as Updates from "expo-updates";

async function checkForUpdates() {
  if (__DEV__) return; // Skip in development

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

    if (update.isAvailable) {
      await Updates.fetchUpdateAsync();

      // Option 1: Immediate reload
      await Updates.reloadAsync();

      // Option 2: Prompt user
      // showUpdatePrompt(() => Updates.reloadAsync());
    }
  } catch (error) {
    console.error("Update check failed:", error);
  }
}

// Hook for update awareness
import { useUpdates } from "expo-updates";

function App() {
  const { isUpdateAvailable, isUpdatePending } = useUpdates();

  useEffect(() => {
    if (isUpdatePending) {
      // An update has been downloaded and is ready
      Updates.reloadAsync();
    }
  }, [isUpdatePending]);
}

Best Practices

  • Use the fingerprint runtime version policy in Expo managed projects. It automatically detects native changes and prevents incompatible OTA updates.
  • Always test OTA updates on the preview channel before pushing to production.
  • Use eas secret for API keys and credentials instead of .env files in CI. Secrets are injected at build time and never appear in logs.
  • Set "autoIncrement": true in eas.json and use "appVersionSource": "remote" so EAS manages build numbers. This avoids conflicts in team environments.
  • Implement staged rollouts for production OTA updates, starting at 10-25% and monitoring crash rates before full rollout.
  • Always include a meaningful --message with OTA updates for auditability.

Common Pitfalls

  • OTA updates that require native changes: If you add a new native module and push an OTA update, the app will crash because the native code is not present. The fingerprint runtime policy prevents this, but custom policies require vigilance.
  • Forgetting to bump runtime version: When using appVersion policy, failing to bump the version after native changes causes OTA updates to target incompatible builds.
  • Large OTA bundles: OTA updates download the entire JS bundle. Large bundles on slow connections lead to timeouts. Monitor bundle size with npx expo export --dump-sourcemap.
  • Not testing the update flow: Test Updates.checkForUpdateAsync() and reloadAsync() in preview builds. The update flow is invisible in development mode.
  • Store review rejections: Apple may reject apps that change core functionality via OTA updates. OTA should be used for bug fixes and minor improvements, not feature overhauls that change the app's purpose.
  • Missing --non-interactive in CI: EAS commands prompt for input by default. Always pass --non-interactive in CI/CD pipelines, or builds will hang.

Install this skill directly: skilldb add react-native-skills

Get CLI access →