Two SDKs both called Firebase
Get this wrong and you'll spend a day wondering why Crashlytics reports nothing.
There are two ways to talk to Firebase from React Native, and they are not interchangeable. Most tutorials don't say which one they're using, which is why so much Firebase-in-RN advice silently fails.
| React Native Firebase | Firebase JS SDK | |
|---|---|---|
| Package | @react-native-firebase/* | firebase |
| What it is | TurboModule wrappers around the real native Android/iOS SDKs | The web SDK, running in your JS engine over HTTP and WebSocket |
| Needs a native build | Yes — dev build or EAS build | No, works in Expo Go |
| Analytics, Crashlytics, Performance | Yes | Impossible — these are native-only |
| Push (FCM) | Yes | Web push only, not mobile |
| Firestore offline cache | Full native persistence | Limited; memory-first |
| SQL Connect | No module | Yes |
For a production mobile app, React Native Firebase is the answer. The three products with the best effort-to-value ratio in all of Firebase — Crashlytics, Analytics, and Cloud Messaging — are native-only and simply do not exist in the JS SDK. There is no workaround; they instrument the OS, not your JavaScript.
The JS SDK earns its place in exactly two situations: a React Native Web build that also has to run in a browser, and SQL Connect, which has no native module. You can install both side by side in one app if you need to — they're separate packages reading the same project config — but keep one library per product, never two clients for the same Firestore.
Version 26 removed the namespaced API entirely. If you find code like firebase.analytics().logEvent(...) or auth().currentUser, it is pre-v22 and will not run. Everything below is modular: import the function, pass the instance in as the first argument. Every snippet on this page is v26 style.
How it actually runs
Four layers. Knowing where each one stops explains most Firebase bugs in React Native.
Your JavaScript never talks to Google. It calls a TurboModule, which calls the real Firebase SDK compiled into your app binary, which talks to Google. That indirection is the source of nearly every confusing symptom in RN Firebase — so it's worth holding in your head:
npx expo run:android or an EAS build. Changing how you call it only changes layer 1, so Fast Refresh is enough.Three consequences fall straight out of that picture:
- Expo Go will never work. It's a pre-built binary containing a fixed set of native modules, and the Firebase SDKs aren't among them. Your calls hit a missing module and do nothing. This is not a bug and there's no flag for it.
- Adding a Firebase package means a rebuild.
npm installplus Fast Refresh is not enough; new native code has to be compiled in. - Authorisation lives server-side. Your JS bundle ships to strangers' phones and can be read. Firestore security rules, Cloud Functions and App Check decide what's allowed — never a check in your React code.
One more grouping worth knowing, because it predicts cost and offline behaviour. Data services (Firestore, Realtime DB, Storage) are two-way round trips billed per operation and cached on device. Engagement services (Messaging, Remote Config, In-App Messaging) push down to the device and are free. Telemetry services (Crashlytics, Analytics, Performance) only ever send upward, are free, and nothing comes back.
Wiring it up in Expo
Config plugins, not manual Gradle edits. Do this once and adding the next module is two lines.
With Expo's Continuous Native Generation, you don't hand-edit build.gradle or the Podfile — you declare config plugins in app.json and let prebuild write the native code. Every RNFirebase module ships one.
npx expo install @react-native-firebase/app \ @react-native-firebase/analytics \ @react-native-firebase/crashlytics \ @react-native-firebase/messaging \ @react-native-firebase/remote-config
{
"expo": {
"android": {
"package": "com.example.app",
// points at the file you downloaded from the console
"googleServicesFile": "./google-services.json"
},
"ios": {
"googleServicesFile": "./GoogleService-Info.plist"
},
"plugins": [
"@react-native-firebase/app", // must come first
"@react-native-firebase/analytics",
"@react-native-firebase/crashlytics",
["expo-build-properties", {
"ios": { "useFrameworks": "static" } // RNFirebase requires this on iOS
}]
]
}
}
One more file, easy to miss, and it's the reason people think Crashlytics is broken locally:
{
"react-native": {
// without this, Crashlytics is a no-op in debug builds
"crashlytics_debug_enabled": true,
"crashlytics_auto_collection_enabled": true,
// useful when you need consent before any collection starts
"analytics_auto_collection_enabled": true,
"messaging_auto_init_enabled": true
}
}
Then build. npx expo run:android compiles a dev client locally; eas build --profile development does it in the cloud. Either way you install that binary once and Metro serves your JS into it exactly like Expo Go — you only rebuild when native dependencies change.
There is no initializeApp() call. The native SDK reads google-services.json at process start, before your JS bundle even loads. So getAnalytics(), getMessaging() and the rest just work at module scope — no async init, no provider component to wrap your tree in.
One wrapper module, always
Before you scatter Firebase imports through your screens: put every @react-native-firebase/* import behind a single module of your own. It takes ten minutes and pays for itself three times — your tests mock one file instead of five packages, telemetry failures can be swallowed in one place (an analytics call must never break a user flow), and swapping or removing a product later is one file's work.
import { getAnalytics, logEvent as fbLogEvent } from '@react-native-firebase/analytics'; import { getCrashlytics, recordError as fbRecordError } from '@react-native-firebase/crashlytics'; const analytics = getAnalytics(); const crashlytics = getCrashlytics(); export function logEvent(name: string, params?: Record<string, unknown>): void { try { void fbLogEvent(analytics, name, params); } catch { /* never throw */ } } export function recordError(e: unknown): void { try { fbRecordError(crashlytics, e instanceof Error ? e : new Error(String(e))); } catch {} }
Telemetry
Free, native-only, and the highest-value thing you can add. Start here.
Crashlytics
@react-native-firebase/crashlyticsRealtime crash, ANR and non-fatal reporting, with identical failures grouped into one issue ranked by how many users it hits.
In React Native it catches three different things: native crashes on either platform, unhandled JS exceptions (which reach it as fatals with the JS stack), and whatever you hand it via recordError. That third one is the workhorse — a caught API failure that you recovered from gracefully is invisible everywhere else, and it's usually the thing that's quietly ruining someone's session.
The grouping is what makes it better than Play Console vitals. A thousand reports collapse into one issue with a user count, an affected-version breakdown and a device distribution, so you can tell "everyone is broken" from "one OEM on Android 12 is broken."
import { getCrashlytics, recordError, log, setUserId, setAttributes, } from '@react-native-firebase/crashlytics'; const crashlytics = getCrashlytics(); setUserId(crashlytics, accountId); // opaque id, never an email await setAttributes(crashlytics, { locale: 'hi', session_mode: 'guest', }); // Breadcrumb + non-fatal. The log lands in the report's timeline. try { await api.fetchForecast(id); } catch (e) { log(crashlytics, `forecast failed for ${id}`); recordError(crashlytics, e as Error); showRetry(); }
Record 5xx, not 4xx. A 400 is user input you already showed a message for; recording it just floods the dashboard until you stop looking at it. Same for plain offline failures. Record server faults, engine errors, and the catch blocks that silently return null.
Google Analytics
@react-native-firebase/analyticsEvent-based product analytics — and the audience engine that Remote Config, A/B Testing, In-App Messaging and FCM all target against.
Its second job matters more than its first. Analytics is what defines an "audience," and every engagement product consumes those audiences. Add it even if you never open the dashboard.
Two RN-specific notes. There's no automatic screen tracking, because Firebase has no idea what React Navigation is — you wire it yourself. And instrument at the store or data layer, on the outcome of an action rather than on button presses, so every screen that triggers the same action is covered once.
import { getAnalytics, logScreenView, logEvent, setUserProperty } from '@react-native-firebase/analytics'; const analytics = getAnalytics(); const navRef = useNavigationContainerRef(); const previous = useRef<string>(); <NavigationContainer ref={navRef} onReady={() => { previous.current = navRef.getCurrentRoute()?.name; }} onStateChange={async () => { const name = navRef.getCurrentRoute()?.name; if (name && name !== previous.current) { // de-dupe, or you double-count previous.current = name; await logScreenView(analytics, { screen_name: name, screen_class: name }); } }} />
// Fire on the outcome, from the store method that owns it. await logEvent(analytics, 'profile_created', { is_self: true, mode: 'account' }); // Durable traits become audience dimensions. Max 25 per project. await setUserProperty(analytics, 'chart_style', 'north'); // GA limits, silently enforced: name ≤ 40 chars, ≤ 25 params, // values ≤ 100 chars. Over-limit events are dropped with no error.
Performance Monitoring
@react-native-firebase/perfApp start time, screen rendering, and the duration and success rate of every network request — measured on real devices in the field.
Install it and app-start and HTTP traces arrive with no instrumentation. Then add custom traces around the flows you care about. The value is that your own phone tells you nothing: the question is what a three-year-old mid-range Android on a patchy connection experiences, and this is how you find out.
import { getPerformance, trace } from '@react-native-firebase/perf'; const t = trace(getPerformance(), 'panchang_compute'); await t.start(); const result = await computePanchang(date); t.putAttribute('source', cached ? 'cache' : 'engine'); t.putMetric('yoga_count', result.yogas.length); await t.stop();
Messaging & config
Changing what the app does, and what it says, without shipping a build.
Cloud Messaging
@react-native-firebase/messagingPush notifications and silent data messages, delivered through the OS's own persistent connection.
There is no realistic alternative on mobile — every push provider you've heard of is a layer on top of FCM and APNs. Two message shapes behave very differently, and the difference catches everyone once:
- A message with a notification payload is drawn by the OS when your app is backgrounded. Your JS never runs until the user taps it.
- A data-only message always reaches your handler, so you control the notification completely — but delivery is subject to Doze and App Standby unless it's high priority.
The RN-specific rule is about where the background handler is registered. It must be at module scope in your entry file, before the app component registers — when a message arrives with your app killed, the OS spins up the JS runtime, runs that file, and expects the handler to already exist. Put it inside a component and it will work in the foreground and mysteriously not in the background.
import { getMessaging, setBackgroundMessageHandler } from '@react-native-firebase/messaging'; // Module scope. Not in a component, not in an effect. setBackgroundMessageHandler(getMessaging(), async (message) => { await cachePanchang(message.data?.date); }); import 'expo-router/entry'; // or registerRootComponent(App)
import { getMessaging, getToken, onMessage, onTokenRefresh, subscribeToTopic, } from '@react-native-firebase/messaging'; import * as Notifications from 'expo-notifications'; const messaging = getMessaging(); // Ask at a moment where the value is obvious — not on first launch. const { status } = await Notifications.requestPermissionsAsync(); if (status !== 'granted') return; const token = await getToken(messaging); await api.registerDevice(token); onTokenRefresh(messaging, (t) => api.registerDevice(t)); // rotates on reinstall // Topics beat per-device fan-out for broadcast content. await subscribeToTopic(messaging, 'daily_panchang_hi'); // Foreground messages don't show a tray notification — you draw them. const unsub = onMessage(messaging, async (m) => showInAppBanner(m.notification));
v26 deprecated RNFirebase's own permission helpers. Use a real notifications library — expo-notifications or Notifee — for permissions, channels and display, and let the messaging module do delivery only. On Android 13+ POST_NOTIFICATIONS is a runtime permission; on iOS you need an APNs key uploaded to Firebase before anything arrives at all.
Remote Config
@react-native-firebase/remote-configServer-controlled key–value pairs the app fetches at runtime, so you can change behaviour without shipping a build.
Feature flags, kill switches, tunable constants, seasonal copy, staged rollouts. You bake defaults into the bundle so the app works offline and on first launch, then override them from the console for everyone or a targeted slice — app version, country, language, Analytics audience, or a random percentage.
In an app store world the kill switch alone justifies it. A broken feature goes off for every user in minutes, instead of however long review plus rollout takes. It also pairs naturally with over-the-air JS updates: config decides whether a feature is on, OTA changes what the feature does.
import { getRemoteConfig, setDefaults, setConfigSettings, fetchAndActivate, getBoolean, getNumber, onConfigUpdated, } from '@react-native-firebase/remote-config'; const config = getRemoteConfig(); await setDefaults(config, { match_feature_enabled: false, free_profile_limit: 3, }); await setConfigSettings(config, { // Default throttle is ~12 hours. Set 0 in dev or you'll // swear your console changes aren't working. minimumFetchIntervalMillis: __DEV__ ? 0 : 3600_000, }); await fetchAndActivate(config); if (getBoolean(config, 'match_feature_enabled')) showMatchTab(); // React to console changes mid-session onConfigUpdated(config, () => fetchAndActivate(config));
A/B Testing & In-App Messaging
console · @react-native-firebase/in-app-messagingControlled experiments on Remote Config values, and console-composed cards and banners for users who are already inside the app.
A/B Testing isn't a separate SDK at all — it's a layer over Remote Config, so if you have that, you have this. Define variants in the console, pick a goal metric, and it handles randomised assignment and the statistics.
In-App Messaging is the counterpart to push: push is for absent users, this is for present ones. Targeting reuses Analytics audiences and events, so "users who opened the match screen three times but never created one" is a trigger you express in the console without shipping code.
Identity & abuse
Who the user is, and whether the caller is really your app.
Authentication
@react-native-firebase/authManaged sign-in — email/password, Google, Apple, phone, anonymous — producing one stable UID and a signed ID token.
Be clear about whether you need this. If you already run your own backend with your own accounts, Firebase Auth is not automatically an upgrade; it's a second identity system to reconcile. But the moment you want Firestore or Cloud Storage, you need it, because their security rules are written against request.auth.uid and nothing else. The bridge between the two worlds is a custom token: your server mints one with the Admin SDK, the app signs in with it, and your existing account id becomes the Firebase UID.
The anonymous provider is the underrated one — call it on first launch and the user has a real UID, real per-user data and real security rules before they've seen a login screen. Link a real provider later and everything carries over on the same UID.
import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged, linkWithCredential, GoogleAuthProvider, } from '@react-native-firebase/auth'; const auth = getAuth(); // Zero-friction start if (!auth.currentUser) await signInAnonymously(auth); // Or bridge your own backend's accounts const { firebaseToken } = await api.login(email, password); await signInWithCustomToken(auth, firebaseToken); // Session state, as a hook useEffect(() => onAuthStateChanged(auth, setUser), []);
Custom claims are how you do roles. Set them server-side with the Admin SDK and they ride inside the ID token, where rules read them as request.auth.token.premium. A role stored in a user-writable Firestore field is not a permission — it's a suggestion.
App Check
@react-native-firebase/app-checkAttests that a request came from your genuine, unmodified app on a real device, and lets Firebase reject everything else.
Auth answers "which user is this?" App Check answers "is this even my app?" Without it, anyone can pull the config out of your APK and hit your Firestore or your AI endpoint from a script — running up your bill and scraping whatever your rules allow signed-in users to see. Android uses Play Integrity, iOS uses App Attest.
Turn it on in monitoring mode first, watch the console until legitimate traffic looks clean, then enforce. You register a debug token for your emulator or CI, or that traffic gets blocked too — budget one confused afternoon for this.
Phone Number Verification
bundled in @react-native-firebase/authGets the device's carrier-verified phone number with user consent — no SMS, no OTP screen, no typing.
Distinct from Auth's phone sign-in, which sends a code the user reads and retypes. This goes to the carrier and returns the number plus a verification token in one call: no delivery failures, no SMS-pumping fraud, no six-digit input. It's aimed at markets where phone-number onboarding is standard and SMS OTP is the biggest drop-off point in the funnel. Keep SMS as a fallback — it won't work on every carrier or on Wi-Fi-only devices.
Data & storage
Optional if you already have a backend — but this is the half of Firebase that costs money, so read the billing note.
Cloud Firestore
@react-native-firebase/firestoreA NoSQL document database that syncs to the device, works offline, and pushes live updates to every listening client.
Data is documents — JSON-ish maps — inside collections, and collections nest under documents. Queries are shallow and indexed: you filter and order, but you cannot join, and every query must be satisfiable by an index. That constraint is what makes cost predictable, since a query's price tracks the size of the result rather than the collection.
The feature you build around is the snapshot listener. Attach one and you get the cached data immediately, then every subsequent change streamed. Writes hit the local cache first and sync when the network returns, so your UI updates instantly whether or not there's signal. In React Native this maps cleanly onto useEffect — and the cleanup function is not optional.
import { getFirestore, collection, query, where, orderBy, onSnapshot, doc, setDoc, } from '@react-native-firebase/firestore'; const db = getFirestore(); function useJournal(uid: string) { const [entries, setEntries] = useState<Entry[]>([]); useEffect(() => { const q = query( collection(db, 'journal'), where('ownerUid', '==', uid), orderBy('createdAt', 'desc'), ); // Returning the unsubscribe is what stops the leak — and the bill. return onSnapshot(q, (snap) => setEntries(snap.docs.map((d) => ({ id: d.id, ...d.data() }) as Entry)), ); }, [uid]); return entries; } await setDoc(doc(db, 'journal', id), { ownerUid: uid, nature: 'good' });
Access control is a separate rules file you deploy alongside the data. Write it the same day you write the first query, not later:
rules_version = '2'; service cloud.firestore { match /databases/{db}/documents { match /journal/{entryId} { allow read, write: if request.auth != null && request.auth.uid == resource.data.ownerUid; } } }
Realtime Database
@react-native-firebase/databaseFirebase's original database: one big JSON tree, synced with very low latency.
Not deprecated, just older. It bills on bandwidth and stored gigabytes rather than per document read, which makes it cheaper when tiny payloads change many times a second — presence, typing indicators, live positions. The trade is a much weaker query model: one child key at a time, no compound queries. For a normal app, Firestore is the better default.
Cloud Storage
@react-native-firebase/storageFile storage for user-generated content, with resumable uploads and the same style of security rules as Firestore.
Databases hold structured fields; Storage holds bytes. The native SDK handles pausing and resuming across network drops, which matters far more on mobile than on the web. Standard pattern: upload the file, then write the resulting path — not the download URL, which can expire — into a Firestore document with your metadata.
import { getStorage, ref, putFile, getDownloadURL } from '@react-native-firebase/storage'; const r = ref(getStorage(), `avatars/${uid}.jpg`); const task = putFile(r, localUri); // a local file path, not a blob task.on('state_changed', (s) => setProgress(s.bytesTransferred / s.totalBytes)); await task; const url = await getDownloadURL(r);
SQL Connect
no native module — JS SDK onlyA managed PostgreSQL database reached through a generated, type-safe SDK — Firebase's answer to "but my data really is relational."
Launched as Data Connect, renamed SQL Connect in 2026. You declare the schema and the allowed operations in GraphQL; Firebase provisions the Postgres tables and generates a typed client containing exactly those operations, so the app can't issue arbitrary queries. Genuinely good — but React Native Firebase has no module for it, so from RN you'd reach it through the firebase JS SDK or behind a Cloud Function. If you want relational data and a native module, that's an argument for keeping your own backend.
Server-side logic
For work that must not happen on a phone.
Cloud Functions
@react-native-firebase/functionsTypeScript or Python that Google runs for you, triggered by a call from your app or automatically by a Firebase event.
Sooner or later something must not live in the bundle: a payment, an API key with real money behind it, a push sent to another user, a field users shouldn't be able to write. That's a function. Two kinds matter here — callable functions you invoke from the app, which get the user's auth and App Check tokens attached automatically, and background triggers that fire on a Firestore write, a new user, a Storage upload or a schedule.
That last one is how you send scheduled push without owning a server: a scheduled function runs daily, queries who wants a notification, and calls FCM through the Admin SDK.
// App import { getFunctions, httpsCallable } from '@react-native-firebase/functions'; const createOrder = httpsCallable(getFunctions(), 'createOrder'); const { data } = await createOrder({ cartId }); // ── functions/src/index.ts ────────────────────────────── export const createOrder = onCall({ enforceAppCheck: true }, async (req) => { if (!req.auth) throw new HttpsError('unauthenticated', 'Sign in'); return { orderId: await charge(req.auth.uid, req.data.cartId) }; }); // Scheduled push, no server of yours involved export const dailyPanchang = onSchedule('0 6 * * *', async () => { await getMessaging().send({ topic: 'daily_panchang_hi', notification: { title: todayTithi(), body: todaySummary() }, }); });
Extensions are pre-packaged functions you install and configure instead of writing — image resizing on upload, Firestore-to-BigQuery sync, transactional email, Stripe subscriptions. Worth checking the catalogue before writing anything generic.
AI
One product, and it replaced three older ones.
Firebase AI Logic
@react-native-firebase/aiCall Gemini models from the app without your API key ever being in the bundle, and without running a proxy server.
The naive approach — a Gemini key in your JS — is the same as publishing it, and a JS bundle is even easier to read than a compiled binary. AI Logic routes through a Firebase-hosted endpoint that holds the credential, enforces App Check and applies your limits. You pick a backend (the Gemini Developer API for the simple path, Vertex AI for enterprise controls) and a model.
The 2026 release added grounding with Google Search and Maps, function calling, and a template-only mode that keeps prompts server-side so they can't be extracted or overridden from the client.
import { getApp } from '@react-native-firebase/app'; import { getAI, getGenerativeModel, GoogleAIBackend } from '@react-native-firebase/ai'; const ai = getAI(getApp(), { backend: new GoogleAIBackend(), appCheck: appCheckInstance, // do not ship without this }); const model = getGenerativeModel(ai, { model: 'gemini-3.8-flash' }); const result = await model.generateContent( `Explain this chart in simple Hindi: ${JSON.stringify(chart)}`, ); setAnswer(result.response.text());
An unprotected AI endpoint is a stranger's free Gemini quota, billed to you. App Check is not optional here in a way it arguably is elsewhere — this is the one product where an abuser's costs land directly on your card.
Release & testing
Two console products, no app code.
App Distribution ships pre-release builds straight to named testers, outside Play's review and track system — an email, a small tester app, and each build lands on their phone. It plugs into EAS and CI, so a merge can be on your QA team's devices in minutes. Note that Expo's own internal distribution covers much of the same ground, so this is worth adding mainly if you want Firebase's tester management and in-app update prompts.
Test Lab runs your app on real physical devices in Google's data centre. Robo test needs no test code at all — it crawls your UI and reports crashes with video — while instrumentation mode runs your suite across a device matrix in parallel. For RN this is the cheapest way to find the crash that only happens on one OEM's skin without buying that phone. If you're already running Maestro flows, those are the natural thing to point at it.
Full module matrix
Every package in the org at v26.4.0, and what it's for. Bookmark this bit.
| Package | Product | Bills on |
|---|---|---|
app | Core — required by every other module | — |
analytics | Google Analytics / GA4 | Free |
crashlytics | Crash & non-fatal reporting | Free |
perf | Performance Monitoring | Free |
messaging | Cloud Messaging (push) | Free |
remote-config | Remote Config & A/B Testing | Free |
in-app-messaging | In-app cards and banners | Free |
app-check | App attestation | Free |
installations | Per-install IDs (used by the above) | Free |
app-distribution | Tester builds | Free |
auth | Authentication + phone verification | 50k MAU free |
firestore | Cloud Firestore | Reads / writes / storage |
database | Realtime Database | Bandwidth / storage |
storage | Cloud Storage for files | Storage / downloads |
functions | Callable Cloud Functions | Invocations / compute |
ai | Firebase AI Logic (Gemini) | Model tokens |
vertexai | Legacy wrapper — use ai | Model tokens |
ml | Firebase ML — shutting down Jun 2027 | — |
Two things Firebase has that RNFirebase doesn't: SQL Connect (JS SDK only) and Hosting / App Hosting (web deployment — though you may still want Hosting for your privacy policy URL, which Play requires, and for the assetlinks.json file behind Android App Links).
What it costs
Two plans. The trap isn't the price — it's which axis you're billed on.
Spark is free with hard caps: no payment method, and service stops when you hit a limit. Blaze is pay-as-you-go — same free allowances, then usage above them — and it's required for Cloud Functions, for any outbound network call from a function, and for most newer products. New Blaze accounts typically get $300 of Google Cloud credit.
The good news for a mobile app: the entire telemetry and engagement half is free at any scale. Analytics, Crashlytics, Performance, Cloud Messaging, Remote Config, A/B Testing, In-App Messaging, App Distribution and App Check never bill on usage. You can run a large app on Spark indefinitely if you don't touch the data products.
| Product | Billed on | Free allowance |
|---|---|---|
| Cloud Firestore | Document reads, writes, deletes · stored GiB | 50k reads, 20k writes, 20k deletes per day; 1 GiB |
| Realtime Database | Bandwidth and stored GB | 1 GB stored, 10 GB/month down |
| Cloud Storage | Stored GB, downloads, operations | 5 GB stored; 100 GB/month download |
| Cloud Functions | Invocations, compute time, egress | 2M invocations/month (Blaze only) |
| Authentication | Monthly active users | 50k MAU |
| AI Logic | Gemini tokens in and out | Depends on backend and model |
| Test Lab | Device-minutes | Daily quota of device time |
Firestore's read counter is where surprise bills come from. Every document a query returns is a billed read, every time. A listener recreated on each render, or a useEffect missing its dependency array, re-subscribes constantly and burns reads at a rate that looks fine with one tester and ruinous at ten thousand users. Paginate with limit(), keep listeners in a store rather than a component, and set a budget alert in Cloud Billing on day one.
Gone and going
Most RN Firebase tutorials online reference at least one of these. Check the date on anything you read.
| What | Status | Instead |
|---|---|---|
Namespaced API — firebase.auth(), analytics() | Deprecated in v22, removed in v26 | Modular: import the function, pass the instance first |
| Dynamic Links | Shut down 25 Aug 2025; package frozen at 22.4.0 | Expo Linking / App Links; a third-party attribution SDK if you need deferred deep links |
messaging().requestPermission() | Deprecated in v26 | expo-notifications or Notifee for permissions and display |
| Old bridge / non-TurboModule setup | Replaced in v26 | New Architecture, on by default in Expo SDK 52+ |
| Firebase ML | Deprecated; shutdown 15 Jun 2027 | ML Kit for on-device vision; AI Logic for generative |
@react-native-firebase/vertexai | Superseded | @react-native-firebase/ai |
| Data Connect | Renamed 2026 | SQL Connect — still JS SDK only |
RN-specific traps
The failures that are about React Native, not about Firebase.
- Testing in Expo Go. Every native Firebase call silently no-ops. You need a dev build. This accounts for most "my events don't show up" threads.
- Forgetting Analytics DebugView. Events batch for up to an hour by default, and the standard Events report lags about 24 hours — never use it to verify a new event. Turn on debug mode (
adb shell setprop debug.firebase.analytics.app <package>) and watch DebugView, which is near-instant. - Crashlytics silent in debug. Without
crashlytics_debug_enabledinfirebase.json,recordErrordoes nothing locally and you'll conclude it's broken. Also: non-fatals upload on the next launch, so kill and reopen the app before checking. - Background message handler inside a component. It must be at module scope in your entry file. Foreground will work, background won't, and the difference is easy to miss in testing.
- Unmocked native modules in Jest. The native modules don't exist in the test environment, so any suite that transitively imports your telemetry module explodes. Mock the Firebase packages globally in your Jest setup file — another reason for the single wrapper module.
- Listeners without cleanup.
onSnapshot,onMessageandonAuthStateChangedall return an unsubscribe function. Return it fromuseEffect. Firestore listeners that leak also bill. - Mixing the two SDKs for one product. Installing both
firebaseand@react-native-firebase/firestoreand using both gives you two independent clients, two caches, and desync bugs that look like ghosts. - One Firebase project for dev and prod. Use separate projects with separate config files per build profile. Test data mixed into production analytics is very hard to undo.
- Skipping the Local Emulator Suite.
firebase emulators:startruns Firestore, Auth, Functions and Storage locally — free, instant, and the only sane way to iterate on security rules. Point the app at it withconnectFirestoreEmulatorin dev. - iOS without
useFrameworks: "static". RNFirebase requires it. The pod install error it produces otherwise doesn't mention Firebase at all.
A worked example
Jyotisha, a Hindu calendar app: Expo 54, RN 0.81.5, RNFirebase 26.4.0, Android-only, Spark plan.
Abstract advice is easy to agree with and hard to act on, so here is a real app at the two-product stage — Analytics and Crashlytics wired, nothing else. It gets the things right that usually go wrong: latest RNFirebase, modular API throughout, every Firebase import isolated in one telemetry.ts, both packages mocked globally in Jest, and crashlytics_debug_enabled set so local testing actually reports. Events sit at the store layer on outcomes rather than on button presses — the right call, and the one most teams get wrong. Nothing in the traps list above applies to it.
So the interesting question is what comes next. On the free plan, in rough order of value per unit of effort:
- 01Remote ConfigFree, no new native surface beyond the package, and it gives you a kill switch plus flags for shipping half-finished features dark. The one to add first — it changes how you release, not just what you measure.
- 02Cloud MessagingDaily panchang and tithi notifications are the obvious fit, and topics (
daily_panchang_hi,daily_panchang_en) mean you don't need to store device tokens at all. Sending on a schedule needs a Cloud Function, which needs Blaze — or your existing backend can call the FCM HTTP API instead and keep you on Spark. - 03User propertiesMissing today.
chart_style,localeandsession_modeturn every existing report into a segmented one for about ten lines of code — the cheapest item on this list. - 04Performance MonitoringFree, and it would show what the forecast run and the astrology engine calls actually cost on a mid-range phone in India, rather than on a developer's emulator.
- 05App CheckWorth it once anything on the backend costs money per call. Lower priority while Firebase is only receiving telemetry.
Firestore, Storage and Auth are the ones to leave alone here. The app already has a backend that owns accounts and profile data, and adding Firebase's versions would buy two sources of truth and a reconciliation problem — a worse trade than the offline sync is worth. That calculus is the general lesson: adopt the free telemetry and engagement half eagerly, and the data half only when you actually need what it does.