- Default Expo behaviour applies updates silently at cold start. That's only good UX if app state doesn't change between bundles.
- Three places OTA bites: mid-session update prompts, schema drift between bundle and cached state, and delayed adoption creating support burden.
- Fix schema drift by tagging AsyncStorage writes with a
_schemaVersionand migrating or evicting at startup. - Expose the current bundle ID in Settings → About so support can diagnose "which bundle are you on?" in one question.
- The technical update should be invisible. The experiential update never should be.
Every React Native OTA update tutorial focuses on the developer experience: eas update, done.
What almost nobody writes about is what happens on the user's phone when your OTA lands, and how that experience shapes whether they stay in the app or bounce.
Let's walk through it.
What happens when an OTA lands
Default Expo behaviour with checkAutomatically: "ON_LOAD":
- User opens the app. Native shell launches, splash screen shows.
- In parallel, the update client checks your server for a new bundle. If found and downloaded within
fallbackToCacheTimeout(default0ms— i.e. never wait), it's applied immediately. - If the timeout hit first, the cached (previous) bundle loads. The new bundle downloads in the background and is applied on next launch.
The user sees nothing. That's the design goal.
But "nothing" is only good UX if the state of the app doesn't change between the two bundles.
The three UX moments where OTA bites
1. Mid-session update prompt
You set checkAutomatically: "ON_ERROR_RECOVERY" thinking you'll only update after a crash. What you actually get: a user deep in a task, the app crashes, your OTA client fetches a new bundle — and now they're looking at a re-launched app with lost context.
Better pattern: ON_LOAD (cold start only) plus an explicit in-app "Update available — restart to apply" banner for warm-session updates.
2. Schema drift between bundle and cached state
Your new bundle expects user.profile.avatarUrl. Users upgrading from an old bundle have user.avatar in cache. Crash on first render.
Solution: version your cache schema. AsyncStorage writes should include a _schemaVersion tag, and new bundles migrate or evict old-schema data at startup.
3. Delayed adoption and support burden
A user emails "this feature is broken." You check the latest bundle — it's fixed. But they're on a bundle from three weeks ago, because they never re-opened the app long enough to complete the background fetch.
Solution: expose the current bundle ID in Settings → About. Support can ask "what does it say?" and immediately know whether they need to force an update.
The "update pending" pattern
import * as Updates from 'expo-updates';
import { useEffect, useState } from 'react';
export function useUpdatePending() {
const [pending, setPending] = useState(false);
useEffect(() => {
const sub = Updates.addListener((event) => {
if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
setPending(true);
}
});
return () => sub.remove();
}, []);
return pending;
}
Then in your app shell:
const pending = useUpdatePending();
return (
<>
{pending && <UpdateBanner onRestart={() => Updates.reloadAsync()} />}
<YourApp />
</>
);
Show the banner unobtrusively — top of screen, dismissible. Let the user restart when they're ready.
Trust beats speed.
Communicating rollbacks
If you roll back a bundle, users who had the bad one for ten minutes may have taken some action in that window started a form, attempted a payment. They come back and the UI has changed under them.
For any rollback that touched user-visible state, ship a small in-app notice:
We reverted an update from an hour ago. If you saw errors, they should be resolved now.
Radical honesty. Most users appreciate being told rather than left confused.
When to break the invisibility rule
For truly major changes — new navigation, a redesigned home screen — an OTA that silently swaps things is worse than a store update with release notes.
Consider gating major UX changes behind an in-app "What's new" modal, even if the code shipped via OTA weeks earlier.
The whole point of OTA is that the technical update is invisible. The experiential update never should be.
If you're starting a React Native project and want the update pipeline wired from day one, RapidNative generates Expo apps with EAS Update already configured.
What's your OTA horror story? Mine was schema drift a cached object shape that crashed every returning user on first render.
Top comments (0)