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.