← Back to the journal

Expo Quirks

Dev builds, prebuild, EAS and OTA updates. The Expo behaviours that look like bugs until you know the rule.

Expo is lovely right up until something behaves in a way the error message doesn’t explain. Almost all of it comes down to three rules. Expo Go is not your app, native config is generated rather than edited, and an update only lands on a build it matches.

Written against Expo SDK 54. The three rules hold across versions. Where a detail is version-specific, the SDK number is called out inline.

Native module not found: Expo Go is not your app

Expo Go is a pre-built app containing the native modules Expo ships. Install anything with its own native code, such as Firebase, most BLE, maps, in-app purchase, notification or camera-adjacent SDKs, and it cannot exist inside Expo Go no matter how correct your JS is.

The fix is a development build. Same dev experience, but it’s your app, with your native dependencies.

npx expo install expo-dev-client
eas build --profile development --platform ios      # or android
npx expo start --dev-client

Locally, without EAS:

npx expo run:android      # prebuilds, compiles, installs on the device
npx expo run:ios

Two related traps. Expo Go only supports the current SDK, and briefly the previous one, so a project on an older SDK won’t open in a freshly installed Expo Go at all. And once you’re on a dev build, npx expo start on its own will still try to talk to Expo Go, so use --dev-client or press s in the CLI to switch.

Install packages the Expo way

npx expo install react-native-svg      # picks the version matching your SDK
npx expo install --check               # list packages on wrong versions
npx expo install --fix                 # move them to the right versions
npx expo-doctor                        # everything else that's misconfigured

npm install react-native-something gets you the latest version, which is routinely incompatible with your SDK’s React Native version. Half of all “random red screen after adding a package” is exactly this, so run npx expo install --check before debugging anything else.

Native config is generated, not edited

With Continuous Native Generation, ios/ and android/ are build artifacts generated from app.json. Hand-editing an Info.plist or AndroidManifest.xml works exactly until:

npx expo prebuild --clean     # deletes and regenerates both folders

Anything that has to change native files belongs in a config plugin. Most libraries ship one, and you add it under plugins in app.json. For the rest, small local plugins (./plugins/with-something.js) are a few lines and survive regeneration.

The corollary: changing app.json does nothing until you rebuild. Permissions, bundle identifier, icon, splash, URL scheme, newArchEnabled, none of it is JavaScript, so Fast Refresh will never pick it up.

If you do commit ios/ and android/, you own them forever. prebuild stops being safe to run, and app.json’s native fields stop being the source of truth.

Environment variables

EXPO_PUBLIC_API_URL=https://staging.example.com

Only EXPO_PUBLIC_-prefixed variables reach your app code, and they are inlined into the bundle at build time. Two consequences people learn the hard way. They are not secret, since anyone can unzip the app and read them, so API keys that matter belong behind your own server. And changing .env requires restarting the bundler, often with npx expo start -c, because the old value is baked into the cached bundle.

For anything that must vary per build, like bundle ID, app name or icons per environment, switch app.json to app.config.js and branch on process.env. That config runs on your machine at build time, so process.env there is your shell or EAS environment, not the device’s.

EAS Build

Your uncommitted work is not in the build. EAS uploads a git archive, so untracked or unstaged files are missing without warning, and a build that fails on a file “that’s right there” is usually a file you never committed. Add .easignore to exclude things from the upload without touching .gitignore.

The artifact also has to match where you’re installing it. These two lines in eas.json are the difference between a working install and a file the simulator refuses:

{
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "ios": { "simulator": true },
      "android": { "buildType": "apk" }
    }
  }
}

Without "simulator": true you get an .ipa signed for real devices that a simulator cannot run. Without "buildType": "apk" you get an .aab, which is for the Play Store and cannot be installed with adb install.

eas build --profile development -p android --local   # build on your machine
eas build --clear-cache                              # when a stale cache is lying
eas build:run -p android --latest                    # install the last build
eas device:create                                    # register a device UDID for iOS ad-hoc

iOS internal distribution is ad-hoc signed, so every physical test device must be registered before the build, and adding one later means rebuilding. In CI, authenticate with an EXPO_TOKEN secret rather than an interactive login.

EAS Update, and why your update didn’t arrive

An OTA update is JavaScript and assets only. It can never change native code, so if the fix involved a new dependency with native bits, it needs a new build.

Updates only reach builds with a matching runtime version and channel. This is the usual reason for “I published it and nothing happened”:

{ "runtimeVersion": { "policy": "appVersion" } }
  • sdkVersion: every build on the same Expo SDK accepts the update.
  • appVersion: tied to your app version, so bumping the version cuts off older installs. Usually what you want.
  • fingerprint: computed from your actual native dependencies, so an update can’t land on a build that couldn’t run it.

Channels are what builds subscribe to, branches are what you publish to, and the channel-to-branch mapping is what routes an update. Check the wiring before debugging the update itself:

eas update --branch preview --message "fix the thing"
eas channel:view preview
eas branch:list

Updates don’t apply in development either. expo-updates is inert while the dev server is running, and doesn’t exist in Expo Go at all. Test on a preview or production build, then cold-start the app twice, since the default behaviour downloads in the background and applies on the next launch.

When the app is behaving impossibly

The cache ladder, cheapest first. Stop as soon as it behaves:

npx expo start -c                      # clear the Metro cache
rm -rf node_modules && npm ci          # after any dependency weirdness
npx expo prebuild --clean              # regenerate native projects
watchman watch-del-all                 # if watchman is installed

Then uninstall the app from the device. It sounds superstitious, but icons, splash screens, notification channels and deep-link registrations are all cached by the OS at install time and will not change under an upgrade install.

Small quirks that cost an afternoon

  • Dynamic require() doesn’t work. The bundler resolves asset paths statically, so require('./icons/' + name + '.png') fails at build time. Build a literal map instead: const icons = { home: require('./home.png') }.
  • Hide the splash screen only after useFonts resolves, or the first frame renders in the fallback font and visibly reflows.
  • For the dev menu: shake the device, Cmd+D on the iOS simulator, Cmd+M on the Android emulator, or adb shell input keyevent 82 when neither works. Press j in the CLI to open React Native DevTools.
  • Reanimated’s babel plugin must be last. Since SDK 50, babel-preset-expo adds it for you when Reanimated is installed; if you list it yourself, it goes at the end of the array. The name changed with Reanimated 4 (SDK 54), which split worklets into their own package, so it’s react-native-reanimated/plugin on Reanimated 3 and react-native-worklets/plugin on 4. Reanimated 4 also requires the New Architecture, so stay on 3 if you’re on legacy. Any babel config change needs npx expo start -c to take effect.
  • npx expo start --tunnel for hostile networks, when your phone and laptop can’t see each other because of corporate wifi, client isolation or a VPN. It’s slower, so don’t leave it on by default.
  • The New Architecture is on by default: for new projects in SDK 52, for everything in SDK 53. If a library hasn’t caught up, "newArchEnabled": false in app.json buys time, but Expo has said SDK 54 is the last release to support the legacy architecture, so treat the opt-out as a deadline rather than a setting. Being native config, it needs a rebuild either way.
  • Monorepos need Metro told about the workspace. Extend the default config with watchFolders pointing at the repo root, and prefer a hoisted node linker with pnpm. Symlink resolution is where most monorepo Metro errors come from.