EAS Update vs Firebase Remote Config in Expo Apps
Once an Expo app is in the stores, there are three ways to change it: a new store build, an EAS Update, or a Firebase Remote Config value. They overlap just enough to cause trouble. Teams end up shipping config as code, code as config, or trying to push a native change over the air. This guide gives each tool one job, wires Remote Config into an app that already uses EAS Update, and ends with a decision matrix and a rollback plan.
The short version
Remote Config changes values. EAS Update changes JavaScript. Store builds change everything else. Ship new code dark with an EAS Update, then switch it on with a Remote Config flag. That way the risky step is a switch you can flip back in seconds. Keep every flag's default in your code, so the default always ships in the same bundle as the code that reads it. One catch before you start: the Firebase JS SDK doesn't support Remote Config in React Native. You need React Native Firebase, which is native code, so Remote Config itself arrives in a store build, not an update.
Versions used, as of September 18, 2026
Expo SDK 57 (expo-updates 57.0), EAS CLI 24.7, and React Native Firebase 26.4. Expo SDK 58 is in beta. I'll update this page if any of the commands change.
1. Three levers, three jobs
Each tool changes a different layer of a shipped app, and each one reaches users in a different way:
- Store build (EAS Build, then EAS Submit): a new binary. It's the only way to change native code, native dependencies, permissions, icons, or the Expo SDK. It goes through review, and users install it whenever they get round to it. Once it's out, you can't take it back.
- EAS Update: a new JavaScript bundle and assets for binaries you've already shipped. By default,
expo-updatesdownloads it in the background when the app starts and runs it on the next launch. It only reaches builds with a matching runtime version. - Remote Config: key-value parameters the app fetches while it runs. It doesn't change any code. It changes which path through your existing code runs. You can target values by platform, app version, country, a random percentage of users, or signals your app sends.
That leaves one rule to remember. Remote Config can only choose between behaviors that are already in the bundle. EAS Update can add behaviors, but only in JavaScript. Anything native needs a build.
2. Runtime versions decide what an update can reach
Every build has a runtime version, a label for the native code inside it. eas update gives each bundle your project's current runtime version, and a build only accepts updates with exactly the same label. That's what stops JavaScript that expects a new native module from reaching a binary without it and crashing at launch.
How the label is worked out depends on the policy in your app config:
// app.json
{
"expo": {
"runtimeVersion": { "policy": "fingerprint" }
}
}appVersionuses yourversionstring. It's easy to read, but it relies on you bumping the version every time native code changes. Forget once and an update goes out to binaries it doesn't fit.fingerprinthashes everything that can affect the native build, such as native dependencies, config plugins, and native app config. The runtime version changes automatically when any of those change. You'll make builds more often, but an incompatible update becomes very unlikely.nativeVersioncombines the version with the build number.
If you're adding React Native Firebase, use fingerprint. Every @react-native-firebase/* package bundles the native Firebase SDKs, so even a patch upgrade is a native change. That's exactly the kind of change people forget to bump a version for. Before you publish to production, check that your project still matches the build you shipped:
eas fingerprint:compare --build-id <production-build-id> --environment productionIf the fingerprints differ, you need a store build, not an update. Expo SDK upgrades always change the runtime too, and my SDK 57 upgrade guide covers that release path.
3. Remote Config needs React Native Firebase
The earlier tutorials in this series use the Firebase JS SDK for Auth, Firestore, and Storage, which all work in Expo without native code. Remote Config is different. Firebase's own environment support table marks Remote Config (and Analytics) as unsupported in React Native. The supported route is React Native Firebase, which wraps the native iOS and Android SDKs.
That has three consequences:
- It won't run in Expo Go. You need a development build.
- The first version of your app that uses Remote Config has to ship as a store build. No update can add it.
- The Remote Config module requires
@react-native-firebase/analyticsas a peer dependency. From v26 it also needs the New Architecture, which is the only architecture current Expo SDKs support, so you already have it.
npx expo install @react-native-firebase/app @react-native-firebase/analytics @react-native-firebase/remote-config expo-build-properties// app.json
{
"expo": {
"ios": {
"bundleIdentifier": "com.example.shop",
"googleServicesFile": "./GoogleService-Info.plist"
},
"android": {
"package": "com.example.shop",
"googleServicesFile": "./google-services.json"
},
"plugins": [
"@react-native-firebase/app",
["expo-build-properties", { "ios": { "useFrameworks": "dynamic" } }]
]
}
}Only the app module needs a plugin entry. Remote Config has no config plugin, and Analytics' plugin is optional and only covers advertising ID settings. React Native Firebase pulls in the Firebase iOS SDK with Swift Package Manager by default, which is why iOS needs dynamic frameworks. Run npx expo prebuild --clean and rebuild. A development build you installed before adding these packages won't contain them.
You don't have to move Auth or Firestore over. Remote Config doesn't need to know who's signed in, so the JS SDK can keep handling Auth and Firestore while React Native Firebase handles Remote Config.
Analytics collects data as soon as it's installed
Installing @react-native-firebase/analytics turns on automatic collection, which you then have to declare in your App Store privacy details and Play data safety form. If you only need it because Remote Config requires it, turn collection off in a firebase.json at the root of your app project, then rebuild:
{
"react-native": {
"analytics_auto_collection_enabled": false
}
}Conditions based on Analytics user properties, and A/B Testing, need collection turned on. Platform, version, country, percentage, and custom signal conditions don't.
4. Defaults in code, new values on the next launch
Give every Remote Config parameter a default in your code. That way the code that reads a flag and its default travel together, in the same bundle. An update that adds a flag also adds its default, and a device that has never fetched still does something sensible.
Firebase describes several loading strategies. The best fit for an app that uses EAS Update is to load new values for the next startup. At launch, activate whatever was fetched during the previous session, then fetch again in the background. Values never change under a user mid-session, and new ones take effect on the next launch, the same way expo-updates applies bundles.
// lib/remoteConfig.ts
import {
activate,
fetchConfig,
getBoolean,
getRemoteConfig,
getString,
setCustomSignals,
} from "@react-native-firebase/remote-config";
import * as Updates from "expo-updates";
export const DEFAULTS = {
new_checkout_enabled: false,
checkout_kill_switch: false,
promo_banner_text: "",
min_supported_version: "1.0.0",
};
const rc = getRemoteConfig();
export async function initRemoteConfig() {
rc.defaultConfig = DEFAULTS;
rc.settings = {
minimumFetchIntervalMillis: __DEV__ ? 60_000 : 12 * 60 * 60 * 1000,
fetchTimeoutMillis: 10_000,
};
// Sent with every fetch, so await it before fetching (see section 5)
await setCustomSignals(rc, {
channel: Updates.channel ?? "development",
runtime_version: Updates.runtimeVersion ?? "development",
});
// Apply what the previous session fetched. This reads from disk, not the network.
await activate(rc);
// Fetch for next launch; the current session keeps its values
fetchConfig(rc).catch(() => {});
}
// Values only change when activate() runs, so reading at render time is fine
export const flags = {
newCheckout: () => getBoolean(rc, "new_checkout_enabled"),
checkoutKilled: () => getBoolean(rc, "checkout_kill_switch"),
promoText: () => getString(rc, "promo_banner_text"),
minSupportedVersion: () => getString(rc, "min_supported_version"),
};Call initRemoteConfig() before you hide the splash screen, for example in the same effect that waits for fonts. The 12-hour production interval is Firebase's default. Don't lower it for everyone, because Firebase throttles apps that fetch too often. For the few values that really have to change faster, use real-time updates.
Real-time updates for kill switches
With a real-time listener attached, the native SDK keeps a connection open, fetches changes as soon as you publish them, and tells you which keys changed. It doesn't activate them for you:
// hooks/useLiveFlag.ts
import { useEffect, useState } from "react";
import {
activate,
getBoolean,
getRemoteConfig,
onConfigUpdate,
} from "@react-native-firebase/remote-config";
export function useLiveFlag(key: string) {
const rc = getRemoteConfig();
const [value, setValue] = useState(() => getBoolean(rc, key));
useEffect(() => {
return onConfigUpdate(rc, {
next: async (update) => {
if (!update.getUpdatedKeys().has(key)) return;
await activate(rc);
setValue(getBoolean(rc, key));
},
error: () => {},
complete: () => {},
});
}, [key]);
return value;
}
// In the checkout screen
const checkoutKilled = useLiveFlag("checkout_kill_switch");Two things to know. First, activate is all or nothing, so it also applies any other pending changes, not just the key you were waiting for. Second, the open connection costs battery and data, so use this hook on the few screens with a kill switch, not for every flag. Listeners share one connection, and it closes when the last one unsubscribes.
5. Target flags at channels and binaries
An EAS Update doesn't change the app version. Remote Config's built-in App version and Build number conditions compare against CFBundleShortVersionString/versionName and CFBundleVersion/versionCode, which only a store build changes. So those conditions can tell binaries apart, but not updates or channels.
Custom signals fill that gap. The setCustomSignals call in initRemoteConfig sends the build's channel and runtime version with every fetch, and you can write conditions against them in the Firebase console:
channelexactly matchespreview: turn a flag on for internal testers before anyone else.runtime_versionexactly matches a fingerprint: turn a feature on only in binaries that contain the native code it needs.- App version is at least
2.4.0(built in): does the same job as the runtime check, as long as you bump versions reliably.
Signals are sent with a fetch, so set them before you fetch, as the code above does. Firebase allows up to 100 signals per app instance, with values up to 500 characters. Keep them free of personal data. A channel name is fine, but an email address isn't.
6. What belongs in Remote Config
Good fits:
- Kill switches for features that are already live.
- Feature flags for code that's already in the bundle.
- Limits the UI uses, like items per page or a retry count. If the server has to enforce a limit, enforce it there too.
- Promo banners and short copy that changes without a code change.
- A minimum supported app version (section 8).
- Percentage rollouts and A/B tests.
Keep out of Remote Config:
- Secrets. Every value is downloaded to every matching device, and anyone can read it there. EAS Build environment variables explains what's safe to ship in an app and what isn't.
- Security decisions. A flag that hides an admin screen doesn't stop anyone calling your backend. Enforce access in Firestore rules and Cloud Functions, and use App Check against unofficial clients.
- Per-user data or anything large. That's Firestore's job.
- Values that must change within minutes for everyone, unless you've set up the real-time listener.
Never change what a key means
Old bundles keep reading a key long after you've moved on. If checkout_config switches from one JSON shape to another, every device still running the previous update gets a shape it can't parse. Add checkout_config_v2 instead, and treat JSON parameters as untrusted input:
export function readJson<T>(key: string, fallback: T, isValid: (v: unknown) => v is T): T {
try {
const parsed: unknown = JSON.parse(getString(getRemoteConfig(), key));
return isValid(parsed) ? parsed : fallback;
} catch {
return fallback;
}
}7. Use EAS Update for JavaScript changes
Good candidates for an EAS Update: JavaScript bug fixes, layout and styling, copy that lives in components, new screens built from native modules the binary already has, image assets, and new flags with their defaults.
Publish to the preview channel first, then to production with a partial rollout:
# Internal testers first
eas update --channel preview --environment preview --message "Fix basket total rounding"
# Then 10% of production
eas update --channel production --environment production --rollout-percentage=10 --message "Fix basket total rounding"
# Check launches and crashes, then widen the rollout
eas update:insights <update-group-id>
eas update:edit <update-group-id> --rollout-percentage=50Only one rollout can run per branch at a time, and you can't publish another update for the same runtime version until it ends. Finish it by setting the percentage to 100, or undo it with eas update:revert-update-rollout.
Always pass --environment
EAS CLI requires --environment for projects on SDK 55 and later, and it matters for Firebase. It decides which EAS environment variables get bundled into the update, including the EXPO_PUBLIC_FIREBASE_* values the JS SDK is configured from. Publish a production update with preview variables and production users start talking to your staging Firebase project. EAS Build environment variables covers the setup.
Updates also have to follow store rules. Apple's guideline 2.5.2 says apps may not download code that "introduces or changes features or functionality of the app". Apple's developer agreement makes an exception for interpreted code like a JavaScript bundle, as long as it doesn't change the app's primary purpose. That exception is why EAS Update is allowed at all. Fixes and small features are routine. Turning the app into something the reviewer never saw is not.
8. Use store builds for native changes
These always need EAS Build and a store release:
- Adding or upgrading any package with native code, including every
@react-native-firebase/*package. - Expo SDK upgrades.
- New permissions and their usage descriptions.
- App config that ends up in the native project: plugins, scheme, icons, splash screen, bundle identifier, entitlements.
- A new
GoogleService-Info.plistorgoogle-services.json.
The EAS Build and Submit checklist covers that release path. Because users update binaries whenever they like, old versions hang around for months. When you really need people off an old binary, a minimum supported version in Remote Config does the job:
// lib/storeVersion.ts
import * as Application from "expo-application";
import { flags } from "./remoteConfig";
function isOlder(current: string, minimum: string) {
const a = current.split(".").map(Number);
const b = minimum.split(".").map(Number);
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const diff = (a[i] ?? 0) - (b[i] ?? 0);
if (diff !== 0) return diff < 0;
}
return false;
}
export function needsStoreUpdate() {
const current = Application.nativeApplicationVersion;
if (!current) return false;
return isOlder(current, flags.minSupportedVersion());
}When it returns true, show a full-screen prompt that links to the store listing. With the next-launch strategy from section 4, a new minimum takes effect one launch after the device fetches it. If you need it faster, read it with the real-time hook. Save this for real breakage, like a backend change the old binary can't handle. Keep your backend accepting the previous version for as long as you can, because users won't all open the app, fetch, and update on the same day.
9. The release decision matrix
| Change | Use | Why |
|---|---|---|
| Fix a JavaScript crash or logic bug | EAS Update | JavaScript only, same runtime |
| Turn off a broken feature right now | Remote Config kill switch | No download or relaunch with a real-time listener |
| Change promo or banner text | Remote Config | It's a value, not code |
| Change copy written inside a component | EAS Update | It's code |
| Add a screen that uses existing native modules | EAS Update, behind a flag | Ship it dark, turn it on separately |
| Roll out a new feature gradually | EAS Update + Remote Config percentage | Code ships once; exposure is a setting |
| Show a feature to internal testers only | Remote Config, channel signal | Same bundle, different audience |
| Add or upgrade a React Native Firebase package | Store build | Native code |
| Add a permission or usage description | Store build | Lives in Info.plist and the manifest |
| Upgrade the Expo SDK | Store build | New runtime version |
| Get users off a broken old binary | Store build + min_supported_version | Only a build fixes native code; the flag gets people to install it |
| Change the shape of a Remote Config value | A new key | Older bundles still read the old one |
10. Ship dark, then flip the flag
Using both tools together looks like this:
- Build the feature behind
new_checkout_enabled, withfalseas its default inDEFAULTS. - Publish an EAS Update to preview. In Remote Config, add a condition where
channelmatchespreview, set the flag totruefor it, and test. - Publish the same code to production. The flag is still off there, so nothing changes for users yet.
- Wait for adoption. A flag only does something on devices running the update that reads it.
eas channel:insights --channel production --runtime-version <runtime>shows how many users have it. - Turn the flag on for 10% of users with a User in random percentage condition. Watch crash rates and support email, then widen it to 100%.
- Clean up. Remove the old code path and the flag check in a later update. Delete the parameter only once hardly anyone is still running an older bundle.
Deleting a parameter isn't neutral
Bundles that still read a deleted parameter fall back to their in-code default, which here is false. Delete new_checkout_enabled while people are still on the update from step 3 and the new checkout turns off for them. Remove the code first, and delete the parameter last.
Flagged features still have to be reviewable. Apple's guideline 2.3.1 bans hidden or dormant features and asks for new features to be described in the review notes and reachable during review. If a flagged feature is in the build you submit, describe it in App Store Connect's Notes for Review and give the reviewer a way to see it.
11. Rollback and monitoring plan
Write these commands down before release day. Each tool rolls back at a different speed.
Remote Config: seconds to hours
The Firebase console's change history lists every published template, and you can roll back to any stored version. Firebase keeps up to 300. From the CLI:
firebase remoteconfig:versions:list --limit 5
firebase remoteconfig:rollback -v 41A rollback publishes a new version containing the old values. Devices with a real-time listener get it within seconds. The rest get it on their next fetch and use it from the launch after that, which with the settings above can take 12 hours or more. That delay is why kill switches need the listener.
EAS Update: a launch or two
If the update is still rolling out, eas update:revert-update-rollout puts everyone back on the previous update. Otherwise, eas update:rollback republishes the update before it, or tells devices to run the bundle embedded in the binary if there's nothing earlier. Devices pick up a rollback like any other update: they download it on one launch and run it on the next.
expo-updates also covers the worst case. If an update crashes before its first screen renders, that device marks it as failed and goes back to the previous one. That only protects you during startup. Bugs that appear later in a session are yours to roll back.
Store builds: no rollback, only phased releases
You can't pull a binary back off users' devices. Use the App Store's seven-day phased release and a staged rollout on Google Play, which you can halt. If a native bug gets through, fix forward with a new build, then raise min_supported_version once the fix is live.
Tag crashes with the release context
A crash spike after you flip a flag could come from the flag or from the update that went out the day before. Tag every crash report with both:
import * as Updates from "expo-updates";
import { flags } from "./remoteConfig";
export function releaseTags() {
return {
update_id: Updates.updateId ?? "dev",
runtime_version: Updates.runtimeVersion ?? "dev",
channel: Updates.channel ?? "dev",
new_checkout: String(flags.newCheckout()),
};
}
// e.g. Sentry.setTags(releaseTags()) after initRemoteConfig()On the EAS side, eas update:insights <update-group-id> shows launches, crashes, and unique users for one update, and eas channel:insights shows adoption for a channel and runtime version.
EAS Update and Remote Config FAQ
Can I use Firebase Remote Config with the Firebase JS SDK in Expo?
No. Firebase's environment support table lists Remote Config as unsupported in React Native for the JS SDK. Use @react-native-firebase/remote-config in a development build instead. It also requires @react-native-firebase/app and @react-native-firebase/analytics to be installed.
Can I add Firebase Remote Config to a live Expo app with an EAS Update?
No. React Native Firebase includes native code, so the first version of your app that uses Remote Config has to ship as a store build. After that, EAS Updates can add new flags and the code that reads them.
Should I fix a bug with EAS Update or Remote Config?
If the fix is code, use EAS Update. If the bug is in a feature that sits behind a flag, switch the flag off in Remote Config first because that reaches users faster, then ship the actual fix with EAS Update.
Does an EAS Update change the app version that Remote Config conditions see?
No. Remote Config's App version and Build number conditions read the native version and build number, and only a store build changes those. To target channels or specific binaries, send custom signals such as the channel and runtime version from expo-updates.
12. Production checklist
runtimeVersionuses thefingerprintpolicy, or you bump the version reliably withappVersion.eas fingerprint:compareagainst the production build before every production update.- Every
eas updatepasses an--environmentthat matches its channel. - Remote Config shipped in a store build before any update reads it.
- Every parameter has a default in code, and the app works offline on its first launch.
- Values activated at launch and fetched in the background, with a 12-hour interval in production.
- Real-time listeners only on screens with kill switches.
channelandruntime_versionsent as custom signals before the fetch.- No secrets, per-user data, or access control in Remote Config.
- No key ever changes meaning; JSON parameters are validated with a fallback.
- New features ship dark and get turned on in stages.
- Flag checks removed from code before their parameters are deleted.
- Flagged features described in the App Review notes.
- Crash reports tagged with update ID, runtime version, channel, and flag values.
- Rollback commands for all three tools written down before release day.
Sources
Expo: Runtime versions | Expo: expo-updates reference | Expo: Rollouts | Expo: Rollbacks | Expo: Error recovery | EAS CLI 24.7 command reference | Firebase: JS SDK supported environments | React Native Firebase: Expo setup | React Native Firebase: Remote Config | Firebase: Remote Config loading strategies | Firebase: Parameters and conditions | Firebase: Templates and versioning | Apple: App Review Guidelines
Related tutorials
Need this built for you? I take on contract Expo and React Native work — see how I can help.