Kirito
The Expo OTA Update That Broke Offline Mode for 10,000 Users
An innocent OTA update changed how AsyncStorage worked, causing 10,000 offline users to lose their data and making the app crash on launch.
The Expo OTA Update That Broke Offline Mode for 10,000 Users
I love Expo's Over-the-Air (OTA) updates. No App Store review. Instant fixes. Happy users.
Until the day OTA updates became my nightmare.
The App
We built a field service app with Expo:
- Technicians working in remote areas (no internet)
- Offline-first with Expo's AsyncStorage
- Sync when connection returns
- 15,000 daily active users
The app worked beautifully offline. Technicians could:
- View assigned jobs
- Log work hours
- Upload photos (stored locally, synced later)
The "Innocent" Update
I needed to add a new feature: job notes with rich text.
Simple change, right?
I updated the data schema:
// BEFORE const job = { id: '123', title: 'Fix AC', status: 'pending' }; // AFTER const job = { id: '123', title: 'Fix AC', status: 'pending', notes: { text: '', richText: '', attachments: [] } };
I updated the AsyncStorage read/write functions:
// BEFORE export const saveJob = async (job) => { const jobs = await getJobs(); const index = jobs.findIndex(j => j.id === job.id); if (index >= 0) jobs[index] = job; else jobs.push(job); await AsyncStorage.setItem('jobs', JSON.stringify(jobs)); }; // AFTER - with migration export const saveJob = async (job) => { const jobs = await getJobs(); // Ensure job has notes field if (!job.notes) { job.notes = { text: '', richText: '', attachments: [] }; } const index = jobs.findIndex(j => j.id === job.id); if (index >= 0) jobs[index] = job; else jobs.push(job); await AsyncStorage.setItem('jobs', JSON.stringify(jobs)); };
I tested on my device. Worked perfectly.
Published OTA update.
The Disaster
Two hours later, my phone started blowing up.
Support tickets: "App crashes on open!"
I checked Sentry:
TypeError: Cannot read property 'text' of undefined
at JobCard.js:47
at renderJobNotes
The crash was happening in the UI:
// JobCard.js - LINE 47 <Text>{job.notes.text}</Text> // notes is undefined for old jobs!
I forgot: OTA updates don't run migrations on existing data.
Users who opened the app after the update:
- App loads
- Reads existing jobs from AsyncStorage (old schema, no
notesfield) - Tries to render
job.notes.text - Crashes
The worst part? Users in offline mode couldn't even reinstall because:
- They were in remote areas with no internet
- The app crashed immediately on launch
- No way to clear storage without reinstalling
10,000 offline technicians couldn't do their jobs.
The Emergency Response
I had to fix this without a native build (App Store review would take days).
Step 1: Release a crash-fix OTA
// FIX - Safely access notes const JobCard = ({ job }) => { // Safe navigation const notesText = job?.notes?.text || ''; return ( <View> <Text>{notesText}</Text> </View> ); };
Published OTA in 5 minutes.
But users who were already crashing couldn't receive the OTA — because the app crashed before checking for updates.
Step 2: Create a recovery build
I had to release a native update with crash recovery:
// App.js - Error boundary with cache clearing class ErrorBoundary extends React.Component { componentDidCatch(error, errorInfo) { console.error('App crashed:', error); // Check if it's a data-related crash if (error.message.includes('Cannot read property')) { // Clear corrupted data this.clearAndReload(); } } clearAndReload = async () => { // Clear AsyncStorage await AsyncStorage.clear(); // Reset app state if (Updates.releaseChannel === 'production') { await Updates.reloadAsync(); } }; }
But native updates take 24-48 hours for App Store review.
Step 3: Manual recovery instructions
I had to post on our support portal:
Emergency Fix for Field Technicians:
- Go to Settings → Apps → Our App
- Tap "Clear Storage" or "Clear Data"
- Reopen the app
- Wait for OTA update to download (needs internet)
But technicians in remote areas couldn't do this.
The Aftermath
- 8 hours of downtime for offline users
- 2,300 support tickets
- 47% of field workers couldn't complete their jobs that day
- Lost revenue: ~$80,000 in billable hours
- 3 technicians quit because of the frustration
All because of one missing optional chain operator.
What I Learned
1. OTA Updates Don't Migrate Data
Expo OTA updates replace JavaScript bundles. They don't run database migrations.
Always assume existing data has the old schema.
2. Use Optional Chaining Everywhere
// BAD job.notes.text // GOOD job?.notes?.text ?? ''
3. Version Your AsyncStorage Keys
const STORAGE_VERSION = 'v2'; const JOBS_KEY = `jobs_${STORAGE_VERSION}`; // On app start, migrate from old version const migrateStorage = async () => { const oldJobs = await AsyncStorage.getItem('jobs'); if (oldJobs) { const migrated = migrateJobs(JSON.parse(oldJobs)); await AsyncStorage.setItem(JOBS_KEY, JSON.stringify(migrated)); await AsyncStorage.removeItem('jobs'); } };
4. Implement Schema Validation
import Joi from 'joi'; const jobSchema = Joi.object({ id: Joi.string().required(), title: Joi.string().required(), status: Joi.string().valid('pending', 'completed').required(), notes: Joi.object({ text: Joi.string().allow(''), richText: Joi.string().allow(''), attachments: Joi.array().items(Joi.string()) }).default({ text: '', richText: '', attachments: [] }) }); const validateJob = (job) => { const { error, value } = jobSchema.validate(job); if (error) { console.warn('Invalid job data:', error); return jobSchema.default(); // Return default } return value; };
The Production Fix
Here's my production-ready AsyncStorage wrapper now:
class SecureStorage { constructor(version = 1) { this.version = version; this.prefix = `app_v${version}`; } async getItem(key, defaultValue = null) { try { const fullKey = `${this.prefix}_${key}`; const value = await AsyncStorage.getItem(fullKey); if (!value) return defaultValue; const parsed = JSON.parse(value); // Validate schema before returning return this.validate(key, parsed) ?? defaultValue; } catch (error) { console.error(`Storage error for key ${key}:`, error); return defaultValue; } } async setItem(key, value) { const validated = this.validate(key, value); if (!validated) throw new Error(`Invalid data for key ${key}`); const fullKey = `${this.prefix}_${key}`; await AsyncStorage.setItem(fullKey, JSON.stringify(validated)); } async migrateFromOldVersion(oldVersion) { const oldStorage = new SecureStorage(oldVersion); const keys = ['jobs', 'user', 'settings']; for (const key of keys) { const oldData = await oldStorage.getItem(key); if (oldData) { await this.setItem(key, oldData); await oldStorage.clearItem(key); } } } }
Expo OTA Safety Checklist
Before every OTA update:
- [ ] Does it change data structure?
- [ ] Are there fallbacks for missing fields?
- [ ] Does the app crash if data is old?
- [ ] Have I tested with REAL production data?
- [ ] Is there an error boundary to catch crashes?
- [ ] Can users clear storage without internet?
Commands for Safe OTA Updates
Test with old data
# Simulate old app version expo start --clear --no-dev # Load production data npx expo export --dump-asset
Rollback OTA
# Publish previous version expo publish --release-channel production --target-version 1.2.5 # Or use EAS Update rollback eas update:rollback --channel production --version 1.2.6
Monitor crashes after OTA
# Check Sentry for new issues sentry-cli events list --org=myorg --project=myapp --query="is:unresolved"
Conclusion
Expo OTA updates are amazing — until they break offline users who can't receive fixes.
Now I have a golden rule: Never change data structure without migration logic AND backward compatibility.
And always, ALWAYS use optional chaining.
That day, 10,000 technicians learned to hate my app.
I learned to respect schema versioning.