Skip to main content

Module 12: Production and Deployment

Over-the-Air Updates

Push JavaScript updates instantly without app store review

đŸŽ¯ Learning Objectives

  • Understand how OTA updates work in React Native
  • Set up EAS Update for your project
  • Configure update channels and branches
  • Publish and manage updates
  • Implement update policies and user experiences
  • Handle rollbacks and version compatibility

What are OTA Updates?

Over-the-Air (OTA) updates allow you to push JavaScript and asset changes directly to users' devices without going through the app store review process. This enables rapid bug fixes and feature deployments.

How OTA Updates Work

flowchart LR
    subgraph Native["Native Binary (App Store)"]
        N1[Native Code]
        N2[React Native Runtime]
        N3[Initial JS Bundle]
    end
    
    subgraph OTA["OTA Update (EAS)"]
        O1[Updated JS Bundle]
        O2[Updated Assets]
    end
    
    subgraph Device["User's Device"]
        D1[Download Update]
        D2[Apply on Next Launch]
        D3[Run Updated Code]
    end
    
    Native --> Device
    OTA --> D1
    D1 --> D2
    D2 --> D3
    
    style OTA fill:#e8f5e9,stroke:#4CAF50
    style Native fill:#e3f2fd,stroke:#2196F3

What Can Be Updated OTA

Can Update OTA ✅ Requires New Build ❌
JavaScript/TypeScript code Native module changes
Images, fonts, sounds app.json/app.config.js changes
Styles and layouts New native dependencies
Navigation structure Permission changes
API integrations Splash screen/icon changes
Bug fixes in JS Expo SDK upgrades

💡 Key Insight

Think of it this way: if your change only affects JavaScript files or static assets that are bundled with your JS, it can be delivered via OTA. If it requires changes to the native iOS or Android projects, you need a new app store build.

Benefits of OTA Updates

⚡ Speed

Deploy fixes in minutes instead of waiting days for app store review.

🔄 Iteration

Test changes with real users quickly and iterate faster.

đŸŽ¯ Targeting

Push updates to specific channels or user groups.

â†Šī¸ Rollback

Instantly revert to a previous version if issues arise.

Setting Up EAS Update

EAS Update is Expo's OTA update service. Let's configure it for your project.

Prerequisites

# Ensure you have the latest EAS CLI
npm install -g eas-cli

# Login to your Expo account
eas login

# Your project should already have EAS configured
# If not, run:
eas build:configure

Install expo-updates

# Install the updates package
npx expo install expo-updates

# This adds expo-updates to your dependencies
# and configures it in app.json

Configure app.json

{
  "expo": {
    "name": "My App",
    "slug": "my-app",
    "version": "1.0.0",
    
    // EAS Update configuration
    "updates": {
      "url": "https://u.expo.dev/your-project-id"
    },
    
    // Runtime version for update compatibility
    "runtimeVersion": {
      "policy": "appVersion"
    },
    
    "extra": {
      "eas": {
        "projectId": "your-project-id"
      }
    }
  }
}

Configure eas.json

{
  "cli": {
    "version": ">= 7.0.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "channel": "development"
    },
    "preview": {
      "distribution": "internal",
      "channel": "preview"
    },
    "production": {
      "channel": "production"
    }
  }
}

Build with Update Support

# Build with a channel configured
eas build --platform all --profile production

# The build will be configured to receive updates
# from the "production" channel

âš ī¸ Important

OTA updates only work with builds that have expo-updates installed and a channel configured. Development builds with developmentClient: true use a different update mechanism (Expo Go or dev client hot reload).

Channels and Branches

EAS Update uses a channel-branch architecture that gives you fine-grained control over which updates reach which users.

Understanding the Architecture

flowchart TB
    subgraph Builds["App Builds"]
        B1[Production Build
channel: production] B2[Preview Build
channel: preview] B3[Dev Build
channel: development] end subgraph Channels["Channels"] C1[production channel] C2[preview channel] C3[development channel] end subgraph Branches["Branches"] BR1[production branch] BR2[staging branch] BR3[feature-x branch] end B1 --> C1 B2 --> C2 B3 --> C3 C1 --> BR1 C2 --> BR2 C3 --> BR3 style Builds fill:#e3f2fd style Channels fill:#fff3e0 style Branches fill:#e8f5e9

Channels

Channels are configured at build time and determine which updates a build can receive.

# Channels are set in eas.json build profiles
{
  "build": {
    "production": {
      "channel": "production"  // This build receives "production" updates
    }
  }
}

# A build can only receive updates from its assigned channel
# You cannot change a build's channel after it's created

Branches

Branches contain the actual updates. You point a channel to a branch to control which updates users receive.

# View all branches
eas branch:list

# Create a new branch
eas branch:create feature-dark-mode

# View updates on a branch
eas branch:view production

# Point a channel to a branch
eas channel:edit production --branch production

# Point channel to a different branch (for testing)
eas channel:edit production --branch staging

Common Workflows

# Workflow 1: Simple (channel = branch)
# Each channel points to a branch with the same name
eas channel:edit production --branch production
eas channel:edit preview --branch preview

# Workflow 2: Staged Rollout
# Test on preview, then promote to production
eas update --branch preview --message "New feature"
# Test with preview users...
eas update --branch production --message "New feature"

# Workflow 3: Feature Flags
# Point some users to a feature branch
eas channel:edit beta-testers --branch feature-new-ui

Channel Management Commands

# List all channels
eas channel:list

# View channel details
eas channel:view production

# Create a new channel
eas channel:create beta

# Edit channel (point to different branch)
eas channel:edit production --branch hotfix-123

# Rollback channel to previous branch
eas channel:rollback production

Publishing Updates

Publishing an update bundles your JavaScript and assets and uploads them to EAS.

Basic Update Command

# Publish to a specific branch
eas update --branch production --message "Fix login bug"

# Publish to multiple platforms
eas update --branch production --platform all

# Publish iOS only
eas update --branch production --platform ios

# Publish Android only
eas update --branch production --platform android

Update with Auto-Versioning

# EAS auto-generates update group IDs
eas update --branch production --message "v1.2.1 hotfix"

# View the update
eas update:list --branch production

# Output shows:
# Update Group ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# Branch: production
# Message: v1.2.1 hotfix
# Runtime Version: 1.0.0
# Platforms: android, ios

Update Management

# List all updates on a branch
eas update:list --branch production

# View specific update details
eas update:view [UPDATE_GROUP_ID]

# Delete an update
eas update:delete [UPDATE_GROUP_ID]

# Republish (create new update from current code)
eas update --branch production --message "Republish"

Interactive Update

# Interactive mode prompts for options
eas update

# Prompts:
# ? Select a branch: production
# ? Enter a message: Fixed payment processing
# ? Select platforms: All

CI/CD Integration

# GitHub Actions example
name: Publish Update

on:
  push:
    branches: [main]

jobs:
  update:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          
      - name: Install dependencies
        run: npm ci
        
      - name: Setup EAS
        uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
          
      - name: Publish update
        run: eas update --branch production --message "${{ github.event.head_commit.message }}" --non-interactive

Update Policies

Control how and when updates are downloaded and applied on user devices.

Update Check Timing

// app.json - Configure when to check for updates
{
  "expo": {
    "updates": {
      "url": "https://u.expo.dev/your-project-id",
      
      // When to check for updates
      "checkAutomatically": "ON_LOAD", // or "ON_ERROR_RECOVERY", "NEVER"
      
      // Fallback behavior
      "fallbackToCacheTimeout": 0
    }
  }
}

Automatic Updates (Default)

// With checkAutomatically: "ON_LOAD"
// 1. App launches with current bundle
// 2. Update check happens in background
// 3. If update available, downloads in background
// 4. Update applies on NEXT app launch

// User experience:
// - No interruption on current session
// - Update ready for next launch
// - May take 2 launches to see new version

Manual Update Control

// For full control, use expo-updates API
import * as Updates from 'expo-updates';

// Check for updates manually
async function checkForUpdates() {
  try {
    const update = await Updates.checkForUpdateAsync();
    
    if (update.isAvailable) {
      // Download the update
      await Updates.fetchUpdateAsync();
      
      // Ask user to restart
      Alert.alert(
        'Update Available',
        'A new version has been downloaded. Restart to apply?',
        [
          { text: 'Later', style: 'cancel' },
          { 
            text: 'Restart', 
            onPress: () => Updates.reloadAsync() 
          }
        ]
      );
    }
  } catch (error) {
    console.log('Error checking for updates:', error);
  }
}

// Call on app start or periodically
useEffect(() => {
  checkForUpdates();
}, []);

Force Update Pattern

// Force critical updates immediately
import * as Updates from 'expo-updates';
import { AppState } from 'react-native';

function useForceUpdate() {
  useEffect(() => {
    const subscription = AppState.addEventListener('change', async (state) => {
      if (state === 'active') {
        try {
          const update = await Updates.checkForUpdateAsync();
          
          if (update.isAvailable) {
            await Updates.fetchUpdateAsync();
            
            // Check if this is a critical update (from your API)
            const isCritical = await checkIfCriticalUpdate();
            
            if (isCritical) {
              // Force restart without asking
              await Updates.reloadAsync();
            }
          }
        } catch (e) {
          // Handle error silently
        }
      }
    });
    
    return () => subscription.remove();
  }, []);
}

Update Status UI

import * as Updates from 'expo-updates';
import { useEffect, useState } from 'react';

function UpdateBanner() {
  const [updateAvailable, setUpdateAvailable] = useState(false);
  const [downloading, setDownloading] = useState(false);
  
  useEffect(() => {
    checkUpdate();
  }, []);
  
  async function checkUpdate() {
    if (__DEV__) return; // Skip in development
    
    try {
      const { isAvailable } = await Updates.checkForUpdateAsync();
      setUpdateAvailable(isAvailable);
    } catch (e) {
      // Ignore errors
    }
  }
  
  async function downloadAndRestart() {
    setDownloading(true);
    try {
      await Updates.fetchUpdateAsync();
      await Updates.reloadAsync();
    } catch (e) {
      setDownloading(false);
      Alert.alert('Update Failed', 'Please try again later.');
    }
  }
  
  if (!updateAvailable) return null;
  
  return (
    <View style={styles.banner}>
      <Text>A new version is available!</Text>
      <Button 
        title={downloading ? 'Updating...' : 'Update Now'}
        onPress={downloadAndRestart}
        disabled={downloading}
      />
    </View>
  );
}

expo-updates API Reference

import * as Updates from 'expo-updates';

// Constants
Updates.channel           // Current channel name
Updates.updateId          // Current update ID
Updates.createdAt         // When current update was created
Updates.isEmbeddedLaunch  // Is running embedded bundle?
Updates.isEmergencyLaunch // Launched due to error recovery?

// Methods
Updates.checkForUpdateAsync()   // Check if update available
Updates.fetchUpdateAsync()      // Download available update
Updates.reloadAsync()           // Restart app with new update
Updates.readLogEntriesAsync()   // Read update logs

// Event Listener
Updates.addListener((event) => {
  if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
    console.log('Update available!');
  }
  if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
    console.log('No updates');
  }
  if (event.type === Updates.UpdateEventType.ERROR) {
    console.log('Update error:', event.message);
  }
});

Runtime Versions

Runtime versions ensure updates are only applied to compatible builds. An update built for one native configuration won't break an incompatible app.

Why Runtime Versions Matter

flowchart TB
    subgraph Problem["Without Runtime Versions"]
        P1[Build v1.0 with SDK 50]
        P2[Update uses SDK 51 API]
        P3[đŸ’Ĩ Crash - API doesn't exist]
    end
    
    subgraph Solution["With Runtime Versions"]
        S1[Build v1.0 - Runtime: 1.0.0]
        S2[Update - Runtime: 1.1.0]
        S3[✅ Update skipped - incompatible]
    end
    
    style Problem fill:#ffebee,stroke:#f44336
    style Solution fill:#e8f5e9,stroke:#4CAF50

Runtime Version Policies

// app.json - Choose a policy
{
  "expo": {
    "runtimeVersion": {
      // Policy options:
      "policy": "appVersion"           // Use app version (1.0.0)
      // "policy": "nativeVersion"      // Use native build version
      // "policy": "sdkVersion"         // Use Expo SDK version
      // "policy": "fingerprintExperimental"  // Auto-detect changes
    }
  }
}

// Or set explicit version
{
  "expo": {
    "runtimeVersion": "1.0.0"
  }
}

Policy Comparison

Policy Value Source Best For
appVersion expo.version in app.json Simple apps, manual control
nativeVersion iOS buildNumber / Android versionCode Apps with native changes per build
sdkVersion Expo SDK version SDK upgrades = new runtime
fingerprint Hash of native dependencies Automatic, most accurate

Fingerprint Policy (Recommended)

// app.json - Automatic runtime version detection
{
  "expo": {
    "runtimeVersion": {
      "policy": "fingerprintExperimental"
    }
  }
}

// Fingerprint automatically changes when:
// - Native dependencies change
// - Expo SDK version changes
// - Native code modifications
// - iOS/Android config changes

// Check current fingerprint
npx expo-updates fingerprint:generate

Managing Multiple Runtime Versions

# Users on different app versions need different updates
# EAS handles this automatically

# Publish update for runtime 1.0.0
eas update --branch production --message "Fix for v1.0"
# Only users with runtime 1.0.0 receive this

# Build new version with runtime 1.1.0
eas build --profile production
# This build has a new runtime version

# Publish update for runtime 1.1.0
eas update --branch production --message "Fix for v1.1"
# Only users with runtime 1.1.0 receive this

# Both versions coexist on the same branch!

Rollbacks and Recovery

When an update causes issues, you need to quickly revert to a working version.

Rollback Strategies

flowchart LR
    subgraph Current["Current State"]
        C1[Bad Update Live]
        C2[Users Affected]
    end
    
    subgraph Options["Rollback Options"]
        O1[Republish Old Code]
        O2[Point Channel to Old Branch]
        O3[Publish Hotfix]
    end
    
    subgraph Result["Result"]
        R1[Users Get Fixed Version]
    end
    
    Current --> Options
    Options --> Result
    
    style Current fill:#ffebee
    style Options fill:#fff3e0
    style Result fill:#e8f5e9

Option 1: Republish Previous Code

# Checkout the previous working commit
git checkout [PREVIOUS_COMMIT_HASH]

# Publish as new update
eas update --branch production --message "Rollback to v1.2.0"

# Users will receive this "new" update
# which contains the old, working code

# Return to main branch
git checkout main

Option 2: Use Channel Rollback

# View channel history
eas channel:view production

# Rollback channel to previous branch state
eas channel:rollback production

# Or point to a specific branch
eas channel:edit production --branch stable-backup

Option 3: Publish Hotfix

# Fix the issue in code
git commit -m "Fix critical bug from v1.2.1"

# Publish immediately
eas update --branch production --message "Hotfix v1.2.2"

# This is usually the fastest option
# if you can quickly identify and fix the issue

Automatic Rollback on Error

// expo-updates has built-in crash recovery
// If an update causes immediate crashes:
// 1. App detects repeated crashes
// 2. Falls back to embedded bundle
// 3. User can continue using app

// Configure fallback behavior
{
  "expo": {
    "updates": {
      "fallbackToCacheTimeout": 0,  // Immediate fallback
      "checkAutomatically": "ON_ERROR_RECOVERY"
    }
  }
}

Testing Before Production

# Always test updates before production!

# 1. Publish to preview first
eas update --branch preview --message "New feature"

# 2. Test with internal team
# Use preview builds to verify

# 3. Only then publish to production
eas update --branch production --message "New feature (tested)"

# Consider a staged rollout:
# - 10% of users first
# - Monitor crash reports
# - Expand to 100%

Best Practices

Update Strategy

✅ Do

  • Test updates on preview/staging before production
  • Use meaningful update messages for tracking
  • Monitor crash reports after publishing
  • Keep runtime versions in sync with native changes
  • Implement graceful update UX for users
  • Have a rollback plan ready

❌ Don't

  • Push updates directly to production untested
  • Ignore runtime version compatibility
  • Force restart without user consent (except critical fixes)
  • Forget to update runtime version when adding native deps
  • Rely solely on OTA for major version changes

Update Frequency Guidelines

// Critical Bug Fixes
// - Push immediately via OTA
// - Test quickly, deploy fast
// - Notify users if needed

// Regular Bug Fixes
// - Batch into weekly updates
// - Test on preview for 24-48 hours
// - Deploy to production

// New Features
// - Consider app store update for major features
// - OTA for minor enhancements
// - Use feature flags for gradual rollout

// Native Changes
// - Always requires new build
// - Plan app store submission
// - Coordinate OTA updates with build releases

Monitoring Updates

// Track update status in analytics
import * as Updates from 'expo-updates';
import analytics from './analytics';

async function trackUpdateStatus() {
  analytics.track('app_launch', {
    updateId: Updates.updateId,
    channel: Updates.channel,
    isEmbedded: Updates.isEmbeddedLaunch,
    createdAt: Updates.createdAt?.toISOString(),
  });
}

// Track update events
Updates.addListener((event) => {
  analytics.track('update_event', {
    type: event.type,
    message: event.message,
  });
});

Version Display for Debugging

// Show version info in settings/about screen
import * as Updates from 'expo-updates';
import Constants from 'expo-constants';

function VersionInfo() {
  return (
    <View>
      <Text>App Version: {Constants.expoConfig?.version}</Text>
      <Text>Build: {Constants.expoConfig?.ios?.buildNumber || 
                    Constants.expoConfig?.android?.versionCode}</Text>
      <Text>Update ID: {Updates.updateId || 'embedded'}</Text>
      <Text>Channel: {Updates.channel || 'N/A'}</Text>
      <Text>Runtime: {Constants.expoConfig?.runtimeVersion}</Text>
    </View>
  );
}

Hands-On Exercises

Exercise 1: Configure EAS Update

Set up EAS Update for a new project with proper channels and runtime versioning.

Show Solution
// 1. Install expo-updates
npx expo install expo-updates

// 2. Configure app.json
{
  "expo": {
    "name": "My App",
    "slug": "my-app",
    "version": "1.0.0",
    "updates": {
      "url": "https://u.expo.dev/YOUR_PROJECT_ID",
      "checkAutomatically": "ON_LOAD",
      "fallbackToCacheTimeout": 0
    },
    "runtimeVersion": {
      "policy": "appVersion"
    },
    "extra": {
      "eas": {
        "projectId": "YOUR_PROJECT_ID"
      }
    }
  }
}

// 3. Configure eas.json
{
  "cli": {
    "version": ">= 7.0.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "channel": "development"
    },
    "preview": {
      "distribution": "internal",
      "channel": "preview"
    },
    "production": {
      "channel": "production",
      "autoIncrement": true
    }
  }
}

// 4. Build with channels
eas build --profile preview --platform all

// 5. Publish first update
eas update --branch preview --message "Initial update"

Exercise 2: Implement Update UI

Create a component that checks for updates and shows a user-friendly prompt.

Show Solution
// components/UpdateChecker.tsx
import * as Updates from 'expo-updates';
import { useEffect, useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';

type UpdateStatus = 'checking' | 'available' | 'downloading' | 'ready' | 'none' | 'error';

export function UpdateChecker() {
  const [status, setStatus] = useState<UpdateStatus>('checking');
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    checkForUpdate();
  }, []);

  async function checkForUpdate() {
    if (__DEV__) {
      setStatus('none');
      return;
    }

    try {
      setStatus('checking');
      const update = await Updates.checkForUpdateAsync();
      
      if (update.isAvailable) {
        setStatus('available');
      } else {
        setStatus('none');
      }
    } catch (e) {
      setStatus('error');
      setError(e instanceof Error ? e.message : 'Unknown error');
    }
  }

  async function downloadAndApply() {
    try {
      setStatus('downloading');
      await Updates.fetchUpdateAsync();
      setStatus('ready');
    } catch (e) {
      setStatus('error');
      setError(e instanceof Error ? e.message : 'Download failed');
    }
  }

  async function applyUpdate() {
    await Updates.reloadAsync();
  }

  if (status === 'none' || status === 'checking') {
    return null;
  }

  return (
    <View style={styles.container}>
      {status === 'available' && (
        <>
          <Text style={styles.text}>Update available!</Text>
          <TouchableOpacity style={styles.button} onPress={downloadAndApply}>
            <Text style={styles.buttonText}>Download</Text>
          </TouchableOpacity>
        </>
      )}
      
      {status === 'downloading' && (
        <>
          <ActivityIndicator color="white" />
          <Text style={styles.text}>Downloading...</Text>
        </>
      )}
      
      {status === 'ready' && (
        <>
          <Text style={styles.text}>Ready to install!</Text>
          <TouchableOpacity style={styles.button} onPress={applyUpdate}>
            <Text style={styles.buttonText}>Restart Now</Text>
          </TouchableOpacity>
        </>
      )}
      
      {status === 'error' && (
        <Text style={styles.errorText}>Update failed: {error}</Text>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    backgroundColor: '#4CAF50',
    padding: 12,
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
    gap: 12,
  },
  text: {
    color: 'white',
    fontWeight: '600',
  },
  button: {
    backgroundColor: 'white',
    paddingHorizontal: 16,
    paddingVertical: 8,
    borderRadius: 4,
  },
  buttonText: {
    color: '#4CAF50',
    fontWeight: '600',
  },
  errorText: {
    color: '#ffcdd2',
  },
});

Exercise 3: Staged Rollout Workflow

Practice a staged rollout: preview → production with proper testing.

Show Solution
# Step 1: Make your code changes
# Edit files, fix bugs, add features

# Step 2: Publish to preview branch
eas update --branch preview --message "New checkout flow v2"

# Step 3: Test with preview builds
# - Install preview build on test devices
# - Verify update is received
# - Test all affected functionality
# - Check for crashes/errors

# Step 4: Monitor for 24-48 hours
# - Check analytics for errors
# - Gather tester feedback
# - Verify performance metrics

# Step 5: If issues found, fix and republish to preview
eas update --branch preview --message "Fix checkout validation"

# Step 6: Once stable, publish to production
eas update --branch production --message "New checkout flow v2 (tested)"

# Step 7: Monitor production
# - Watch crash reports
# - Monitor user feedback
# - Be ready to rollback if needed

# Rollback if critical issues arise:
git checkout HEAD~1  # Go to previous commit
eas update --branch production --message "Rollback: checkout issues"

Summary

đŸŽ¯ Key Takeaways

  • OTA updates: Push JS/asset changes without app store review
  • Channels: Configured at build time, determine which updates a build receives
  • Branches: Contain actual updates, can be swapped for channels
  • Runtime versions: Ensure update compatibility with native code
  • Update policies: Control automatic vs manual update behavior
  • Rollbacks: Republish old code or point channel to stable branch
  • Best practice: Always test on preview before production

EAS Update Command Reference

# Publishing
eas update --branch [BRANCH] --message "[MSG]"
eas update --branch production --platform ios

# Branch Management
eas branch:list
eas branch:create [NAME]
eas branch:view [NAME]
eas branch:delete [NAME]

# Channel Management
eas channel:list
eas channel:create [NAME]
eas channel:view [NAME]
eas channel:edit [NAME] --branch [BRANCH]
eas channel:rollback [NAME]

# Update Management
eas update:list --branch [BRANCH]
eas update:view [UPDATE_GROUP_ID]
eas update:delete [UPDATE_GROUP_ID]

In the next lesson, we'll cover deploying your app to the Apple App Store and Google Play Store.