Skip to content

OTA Updates

Metadata

  • System type: flow

System Intent

  • What this is: Expo EAS Update integration that enables the encache mobile app to receive JavaScript and asset updates without app store resubmission. Updates are distributed through Expo's CDN to devices subscribed to one of three independent channels (development, staging, production). The app checks for updates on every launch and applies them automatically before the user sees the UI.

Mermaid Diagram

graph TB
    A["Developer / CI"] -->|"eas update --channel <channel>"| B["EAS Update Service\n(Expo CDN)"]

    subgraph "Configuration"
        F["app.json\nupdates.url, checkAutomatically"]
        G["eas.json\nchannels + build profiles"]
    end

    F -->|"configures"| D
    G -->|"configures"| B

    B -->|"update bundle available"| D

    subgraph "Mobile App (index.tsx)"
        D["Root component\n__DEV__ guard"] -->|"Updates.checkForUpdateAsync()"| E["expo-updates library"]
        E -->|"isAvailable=true"| H["Updates.fetchUpdateAsync()"]
        H -->|"success"| I["Updates.reloadAsync()"]
        E -->|"isAvailable=false or error"| J["SplashScreen.hideAsync()\nrender App normally"]
        I -->|"JS engine reloads"| D
    end

    K["publish-update.sh"] -->|"validates git clean\nruns eas update"| B
    L[".github/workflows/mobile-release.yml"] -->|"push to main → staging\npush to release/* → production"| B

Flows

Flow: initializeOTAConfig

  • Test files: apps/mobile/tests/ota-config.test.ts
  • Core files: apps/mobile/app.json, apps/mobile/eas.json, apps/mobile/package.json

Types

AppJsonUpdates {
  enabled: boolean (must be true)
  checkAutomatically: "ON_LOAD" | "ON_ERROR_RECOVERY" | "NEVER"
  fallbackToCacheTimeout: number (ms; set to 30000)
  url: string (https://u.expo.dev/<PROJECT_ID>)
}

EasJsonUpdate {
  enabled: boolean
  channels: string[] (["development", "staging", "production"])
}

EasJsonBuild {
  development: { developmentClient: true, distribution: "internal" }
  preview: { distribution: "internal" }
  production: { distribution: "store" }
}

Paths

path input output path-type notes
initializeOTAConfig.valid app.json + eas.json + package.json configuration accepted by eas-cli happy path expo-updates plugin registered in app.json plugins array
initializeOTAConfig.missingUrl app.json without updates.url CI validation step fails error Caught by mobile-release.yml node validation step
initializeOTAConfig.missingChannels eas.json with <2 channels CI validation step fails error Caught by mobile-release.yml node validation step

Flow: publishUpdate

  • Test files: apps/mobile/tests/ota-publish.test.ts
  • Core files: apps/mobile/scripts/publish-update.sh, .github/workflows/mobile-release.yml

Types

PublishInput {
  channel: "development" | "staging" | "production" (default: "staging")
  message: string (default: "Automated OTA update")
}

PublishOutput {
  updateId: string (uuid assigned by EAS)
  publishedAt: ISO8601 timestamp
  channel: string
  commitHash: string (short SHA captured at publish time)
  branch: string
}

PublishError {
  cause: "dirty-working-tree" | "auth-failure" | "bundle-too-large" | "network-failure"
  message: string
}

Paths

path input output path-type notes
publishUpdate.success PublishInput with clean git tree PublishOutput logged to stdout happy path eas update exits 0; devices begin polling
publishUpdate.dirtyTree working directory has uncommitted changes PublishError cause=dirty-working-tree, exit 1 error Script checks git status --porcelain before calling eas
publishUpdate.authFailure invalid or expired EXPO_TOKEN PublishError cause=auth-failure error Re-authenticate via eas login or rotate EXPO_TOKEN secret
publishUpdate.bundleTooLarge bundle > 50 MB PublishError cause=bundle-too-large error Implement code splitting or remove unused assets

Pseudocode

FUNCTION publishUpdate(channel, message):
  IF git status --porcelain is non-empty:
    PRINT "Uncommitted changes"
    EXIT 1

  commit = git rev-parse --short HEAD
  branch = git rev-parse --abbrev-ref HEAD

  RUN: eas update --channel channel --message message
  IF exit code != 0:
    PRINT "Failed to publish to " + channel
    EXIT 1

  PRINT success summary (channel, message, branch, commit)

CI triggers (mobile-release.yml): - push to maineas update --channel staging - push to release/*eas update --channel production - workflow_dispatch → user-specified channel and message


Flow: appReceivesUpdate

  • Test files: apps/mobile/tests/ota-update-apply.test.ts
  • Core files: apps/mobile/index.tsx

Types

UpdateCheckResult {
  isAvailable: boolean
}

UpdateError {
  message: string (non-fatal; app continues with current bundle)
}

Paths

path input output path-type notes
appReceivesUpdate.updateAvailable production app launches, new bundle on CDN bundle downloaded, Updates.reloadAsync() called, UI renders new code happy path Splash screen held visible during download; [OTA] log prefix used throughout
appReceivesUpdate.upToDate production app launches, no new bundle SplashScreen.hideAsync(), App renders with current bundle happy path Logged as "[OTA] App is up to date"
appReceivesUpdate.devMode __DEV__ === true update check skipped entirely happy path Logged as "[OTA] Running in development mode, skipping update check"
appReceivesUpdate.checkError network timeout or EAS unreachable error caught silently, app continues on current bundle error Error is swallowed in catch block; app launch is not blocked
appReceivesUpdate.fallbackTimeout update check takes > 30 s fallbackToCacheTimeout (30000 ms) triggers, cached bundle used error Configured in app.json updates.fallbackToCacheTimeout

Pseudocode

// apps/mobile/index.tsx — Root component useEffect

SplashScreen.preventAutoHideAsync()

async function initializeApp():
  TRY:
    IF NOT __DEV__:
      update = await Updates.checkForUpdateAsync()
      IF update.isAvailable:
        await Updates.fetchUpdateAsync()
        await Updates.reloadAsync()   // JS engine reloads; execution stops here
  CATCH error:
    setUpdateError(error)             // ErrorBoundary shows message; app continues
  FINALLY:
    setIsReady(true)
    await SplashScreen.hideAsync()

Flow: rollbackUpdate

  • Test files: apps/mobile/tests/ota-rollback.test.ts
  • Core files: apps/mobile/scripts/publish-update.sh, EAS dashboard

Types

RollbackInput {
  channel: "staging" | "production"
  previousUpdateHash: string (from `eas update list` output)
  rollbackMessage: string
}

RollbackOutput {
  newCurrentUpdateId: string
  devicesCoverage: "95%+ within 5 minutes" (expected SLA)
}

Paths

path input output path-type notes
rollbackUpdate.success RollbackInput with valid hash previous bundle published as current; devices revert on next launch happy path OTA does not support native rollback; JS/asset only
rollbackUpdate.hashUnknown previous hash not recorded manual investigation required error Keep eas update list output in post-deploy notes
rollbackUpdate.devicesOffline devices offline during rollback window affected devices remain on broken version until next launch error No mitigation beyond waiting; update applies on reconnect

Flow: manageChannels

  • Test files: apps/mobile/tests/ota-channels.test.ts
  • Core files: apps/mobile/eas.json

Types

Channel: "development" | "staging" | "production"

ChannelConfig {
  development: { developmentClient: true, distribution: "internal" }
  preview:     { distribution: "internal" }
  production:  { distribution: "store" }
}

UpdateChannels {
  enabled: true
  channels: ["development", "staging", "production"]
}

Paths

path input output path-type notes
manageChannels.independent update published to staging only staging devices receive update; production devices unaffected happy path Channels are fully independent in EAS
manageChannels.promotion staging validated, publish to production production devices receive update on next launch happy path Recommended: 30+ min staging soak before promotion
manageChannels.misconfigured channel name not in eas.json channels array publish-update.sh exits 1 with "Invalid channel" error error Script validates channel against allowed list before calling eas

Logs

Source Location
Update check console.log("[OTA] ...") in apps/mobile/index.tsx at runtime
Update error console.error("[OTA] Error checking for updates:", error) in apps/mobile/index.tsx
Publish success stdout of publish-update.sh or GitHub Actions step logs
CI build/publish .github/workflows/mobile-release.yml job logs in GitHub Actions
EAS dashboard https://expo.dev — per-channel update history and device coverage

Deployment

  • Mechanism: Expo EAS Update (managed cloud service)
  • Deploy command:
    # Manual publish to a channel
    cd apps/mobile
    ./scripts/publish-update.sh staging "Fix: crash on memory pressure"
    
    # Or directly via eas-cli
    eas update --channel production --message "Release v1.2.0"
    
    # Or via GitHub Actions (manual trigger)
    gh workflow run mobile-release.yml -f channel=production -f message="Release v1.2.0"
    
  • Notes:
  • OTA updates apply to JavaScript and asset changes only; native code changes require a full eas build and app store submission.
  • app.json updates.url must be set to https://u.expo.dev/<YOUR_PROJECT_ID> before the first publish. The placeholder YOUR_PROJECT_ID must be replaced with the actual EAS project ID.
  • CI publishes staging automatically on every push to main; production publishes automatically on every push to release/* branches.
  • Devices use checkAutomatically: "ON_LOAD" with a 30-second fallbackToCacheTimeout; if the update check exceeds 30 seconds the cached bundle is used instead.
  • Rollback is performed by republishing a previous update bundle to the same channel via eas update.