We just spent a few hours wrestling with a classic “It works on Web but fails on Native” bug. It turned out to be a perfect storm of SDK Mismatches, Metro Bundling quirks, and Aggressive Offline Caching.
Here is the technical breakdown of what happened and how we fixed it.
The Problem
We have a Monorepo sharing code between Expo Web and Expo Native (Android).
- Web worked perfectly: Users could sign up, and their documents appeared in Firestore.
- Native failed: Authentication worked (Google Sign-In), but no user documents were created in Firestore.
And confusingly, the logs sometimes claimed [UserService] User already exists even after we deleted the user from the Firebase Console!
Root Cause 1: The “SDK Cousin” Problem
In the JS world, Firebase has two different SDKs that look similar but are incompatible:
- Web SDK (
firebase/firestore): Uses a “Modular” API (e.g.,doc(db, "users", uid)). - Native SDK (
@react-native-firebase/firestore): Uses an “Object-Oriented” API (e.g.,db.collection("users").doc(uid)).
Our shared code was trying to use the Web SDK syntax with the Native SDK instance. This caused crashes like:
[FirebaseError: Expected first argument to doc() to be a CollectionReference...]
The Fix: Platform-Specific Extensions (.native.ts)
We split our logic into two files. Metro (the React Native bundler) automatically picks the right one based on the extension!
UserService.web.ts (Standard Web SDK):
import { doc, setDoc } from 'firebase/firestore';
// ...
await setDoc(doc(db, 'users', uid), { ... });
UserService.native.ts (React Native Firebase):
import firestore from '@react-native-firebase/firestore';
// ...
await firestore().collection('users').doc(uid).set({ ... });
This resolved the crashes, but we still didn’t see documents created.
Root Cause 2: The “Ghost” Cache 👻
After fixing the code, the native app refused to write new documents, logging:
User already exists, skipping creation.
We checked the Firebase Console: The user was definitely deleted. So why did the app think it existed?
Firebase Offline Persistence.
Mobile apps are designed to work offline. When we ran the app successfully once in the past (maybe via Web code running on a cached session), Firebase cached that “User X exists”.
Even after deleting the data on the server, the app’s get() request hit the Local Cache first, saw the old “Exists” record, and returned true.
The Fix: Trust No One (Just Write!)
We initially tried to force a server check (get({ source: 'server' })), but persistent cache issues made this flaky.
The robust solution was to change our philosophy: Idempotence. Instead of “Check if exists, then create”, we changed to “Always Write (Upsert)”.
// Old Logic (Flaky Cache)
if (await user.exists()) return; // Cache says "Yes", so we skip writing
await user.set(data);
// New Logic (Robust)
// Just write it! set() merges or overwrites safely.
await user.set(data);
This bypasses the read cache entirely. If the user is new, it creates them. If they exist, it updates their timestamp.
Lessons Learned
- Never mix Firebase Web and Native SDK objects. They are different animals.
- Use
.native.tsfiles for clean platform splitting in Expo Monorepos. - Don’t trust
get()on mobile for critical “exists” checks unless you handle offline state. Prefer idempotent writes!