On 29 August 2026 we published an over-the-air update to Dusora, our dream journal for iOS. It crashed the app in every binary built before two packages were added to the project, which on that day meant the App Store version. Three days later the same thing happened to TestFlight builds of Moonhush, our bedtime story app, although the new package there was loaded lazily, with a .catch.
In both apps the package's entry file calls requireNativeModule as it is evaluated, which throws when the binary has no such module. Whether that can be caught depends on where the import runs. At the top level of a module, a try/catch around require catches it. Inside an effect or an event handler nothing does, not even the .catch of a dynamic import(): the module runtime reports the error as fatal instead of throwing it. We now ask the binary first and import only if the module is there.
Dusora was on Expo SDK 54 (expo-updates 29.0.16, React Native 0.81.5), Moonhush on SDK 57 (expo-updates 57.0.19, React Native 0.86.3).
What an Expo update can change
Both apps send JavaScript-only changes with EAS Update. In Expo's words an update changes an app's “non-native pieces (such as JS, styling, and images)”; native code, native dependencies and permissions need a new binary. The app looks for an update when it is opened and takes only one published for its own platform and runtime version.
So every release has two halves of different ages: the binary, built whenever that user last installed from the store, and the JavaScript, written today. Every import of a package with native code is therefore a claim about an older binary.
The first crash: Dusora, 29 August 2026
Voice capture and PDF export were new, built on expo-speech-recognition and expo-print, and we had marked both as needing a new build. The JavaScript that went out to App Store builds already imported the two packages at the top of two files: the voice hook, used by the screen for adding a dream, and the export helper, used by the data settings screen. The App Store version, 1.0.0, had been built before either package was in the project, so neither import could succeed there.
New JavaScript asked an older binary for native modules it did not contain.
We did not record how we first noticed the crash, how long that update stayed the newest on its channel, or when the corrected one replaced it. Neither app includes a crash-reporting SDK, so we have no count of affected installs.
The fix was committed the same day. It removed the top-level imports and loaded the speech module like this:
// Guarded require: the native module is missing in binaries built before it
// was added, and `requireNativeModule` throws at import time — an eager import
// would crash the whole app on launch via OTA. Resolve lazily and degrade to
// "unavailable".
function loadSpeechModule(): SpeechModule | null {
if (Platform.OS === 'web') return null;
try {
// biome-ignore lint/suspicious/noExplicitAny: guarded dynamic require
return (require('expo-speech-recognition') as any)
.ExpoSpeechRecognitionModule ?? null;
} catch {
return null;
}
}
const speech = loadSpeechModule();
const isAvailable = speech?.isRecognitionAvailable?.() ?? false;
The last two lines run at the top level of the hook's file. The package's event hook had to go as well, so listeners now subscribe through speech.addListener. PDF export got the same try/catch, inside the export function.
Later that day a top-level import of expo-clipboard crashed the referral screen as it opened. We moved it into the tap handler as an await import() inside try/catch and wrote a rule: a new native module is imported only inside the handler that uses it, with try/catch.
The second crash: Moonhush, 1 September 2026
Moonhush had not been released: we first submitted it to App Review on 13 September 2026, so the builds that crashed were TestFlight builds. (Another article covers how Moonhush generates a story within sixty seconds.)
We added a star sky that drifts as the phone tilts, using the accelerometer from expo-sensors 57.0.2. The import followed the rule: a dynamic import() with a .catch, inside useEffect. The update crashed older builds on launch with this message:
Cannot find native module 'ExponentPedometer'
The app had asked for the accelerometer. The error names the pedometer because the package's index file imports every sensor, each sensor's file calls requireNativeModule as it is evaluated, and the pedometer comes first.
We saw the crash within minutes: the fix was committed thirteen minutes after the feature. We did not note when the corrected update went out. It is one line at the top of the effect (tx and ty are the sky's animated offsets):
useEffect(() => {
// … listener handle and tilt baseline
// Builds without the expo-sensors native module just get a static sky. The
// probe must run BEFORE the import: the package index requires every sensor
// module at scope, and a missing one throws fatally on load — a .catch on
// the import does not save the old binaries.
if (!requireOptionalNativeModule('ExponentAccelerometer')) return;
import('expo-sensors')
.then(({ Accelerometer }) => {
Accelerometer.setUpdateInterval(120);
// … subscribe and move the sky with the tilt
})
.catch(() => {});
// … cleanup
}, [tx, ty]);
requireOptionalNativeModule is exported by the expo package and returns null where requireNativeModule throws.
Why try/catch worked once and .catch did not
A Metro bundle loads its modules through a small runtime at the top of the file; Expo's CLI ships its own copy. Every require of a module that is not loaded yet goes through this function, shown as published in metro-runtime 0.83.3 (source on GitHub):
let inGuard = false;
function guardedLoadModule(moduleId, module) {
if (!inGuard && global.ErrorUtils) {
inGuard = true;
let returnValue;
try {
returnValue = loadModuleImplementation(moduleId, module);
} catch (e) {
global.ErrorUtils.reportFatalError(e);
}
inGuard = false;
return returnValue;
} else {
return loadModuleImplementation(moduleId, module);
}
}
While a module is being evaluated, inGuard is true. A require that runs then takes the second branch, and a throw from the required module travels up the stack like any exception. That is the Dusora speech guard: loadSpeechModule() is called at the top level of the hook's file, so its catch receives the error.
Once loading has finished, inGuard is false. A require that runs later, from an effect, an event handler or a timer, takes the first branch. The runtime catches the throw itself, passes it to ErrorUtils.reportFatalError and returns undefined. Nothing is thrown, so no catch runs. React Native shows a fatal report as an error screen in a development build and ends the process in a release build.
A dynamic import() is no different: Expo's implementation of import() calls the same require synchronously. A remark there, written about split bundles, describes the same behaviour: “On native, requiring a missing module reports a fatal error via global.ErrorUtils instead of throwing”. The promise resolves, with an empty module, after the fatal error has been reported. A .catch has no rejection to catch.
We read this in Expo CLI's copy, which is what both apps bundle, and in metro-runtime 0.83.3 and 0.84.5, and ran each file alone with a module that throws; each behaved as described. We did not rebuild an old binary; the two crashes are the only evidence from devices.
So the first fix held for a reason we had not understood. Removing the top-level imports stopped that crash, and the speech guard works because it runs at module scope. The PDF guard is reached from a tap, where its catch gets nothing: on a 1.0.0 binary that tap would have been a fatal error, and we did not test that on one. The rule we wrote that day named the handler, which is where a failed load cannot be caught.
Those guards are still in Dusora as we wrote them. Every binary since version 1.1.1, submitted on 13 September 2026, contains the modules, so nothing depends on them there.
What a phone does with a crashing update
A phone that already has the bad update relies on expo-updates' error recovery. If an update throws a fatal error on its first launch, before anything has rendered, the library marks it as failed on that device. It waits up to five seconds for a newer update and otherwise goes back to the last one that launched successfully.
If the error comes within ten seconds after the first render, the library tries to download a newer update and lets the app crash; the newer one runs on the next launch. A fatal error later than that is outside the recovery flow altogether: the app crashes, and the phone gets the fix through the ordinary check at a later launch. We did not record what the affected phones did, so this is the documented path, not an observation.
Which binaries an update reaches
Both apps set runtimeVersion to the appVersion policy, so the runtime version is the app version in app.json.
Raising the version cuts off the released binaries. Once the version in the repo is raised for the next store release, an update published from that state targets the new runtime. To reach the version people have, we set the version back in the working tree for the publish only and restore it afterwards. Publishing from the release commit would send the old JavaScript. In October 2026 an AI agent working in the Dusora repo first answered that the older version could not be reached at all; we asked again, and the channel's history showed an update for it from the day before.
What we check before an update goes out
We still send JavaScript-only fixes over the air. A lazy import decides when a package loads, not whether loading it is safe. Before publishing:
- Does the JavaScript load a package whose native module some released binary lacks? Probe first (
requireOptionalNativeModule, orNativeModules.<Name>outside Expo modules), then import. - Has it been loaded into a binary without the module? We build with stale pods (
expo run:ios --no-install); a current development build always contains the module. After the second crash, Moonhush's next native dependency went in behind a probe the same afternoon; on the stale build its button stayed hidden. - Could the change avoid the question? A later Moonhush spec has it as a binding principle: “Over-the-air safe. No new dependency and no native module that is not already in
package.json.” - Which runtime version is it published for, and what did the channel last send there?
- Does the fingerprint differ from the released build in anything but the version? On expo-updates 29.0.16 the version is part of the hash, so compare the two lists of sources that
expo-updates fingerprint:generateprints, not the two hashes. - Is the rollback written down? In Dusora's changelog the entries for the latest updates include the
eas update:republishcommand that rolls them back.
None of these checks is enforced by a tool. Under appVersion an update with a new native dependency still reaches older binaries. Expo's fingerprint policy changes the runtime version “whenever anything that may impact the native runtime changes”, so by that description it would keep such an update away; both apps are still on appVersion.