Run flutterfire configure to register the apps and generate firebase_options.dart, add firebase_core plus one plugin per product, and in main() await only Firebase.initializeApp() before runApp.
What the CLI writes: lib/firebase_options.dart with one FirebaseOptions per platform, plus the native google-services.json and GoogleService-Info.plist that push, Crashlytics and Google sign-in read
**Order in main()**: WidgetsFlutterBinding.ensureInitialized() comes first, because initializeApp talks to native code over a platform channel
One package per product: firebase_auth, cloud_firestore, firebase_messaging; re-run flutterfire configure after adding one that needs native setup, such as Crashlytics
Keep startup short: FirebaseFirestore.instance and the other entry points are lazy, so nothing else needs awaiting; a profile fetch awaited before runApp hangs behind a blank screen when the phone is offline
FlutterMiddle
Firebase & Backend Services
FlutterFire setup, Auth, Firestore, FCM, Remote Config, Crashlytics, App Check, Supabase
Questions closed
0 / 24
- 0Review
- 2Asked often
Questions
get() reads once and returns a Future; snapshots() returns a Stream that emits now and again on every change until you cancel it. Writes are add, set, update and delete on a collection or document reference.
**get()**: a one-off read for data that rarely changes or a pull-to-refresh; it asks the server and falls back to the cache offline, and GetOptions(source: Source.cache) reads only the cache
**snapshots()**: a realtime listener: the first event often comes from the cache, then changes stream in; it lives until cancelled (StreamBuilder does that on dispose) and keeps billing reads for every change
**add vs set**: add creates a document with an auto id; doc(id).set(data) replaces the whole document, and SetOptions(merge: true) merges fields instead
**update**: changes only the given fields and fails with not-found if the document does not exist
**delete: removes the document but not its subcollections
Typed data**: withConverter maps to your model once instead of doc['field'] casts everywhere
Firestore stores documents in collections, supports indexed compound queries and bills per document operation; Realtime Database is one JSON tree with very low latency and shallow queries, billed by storage and bandwidth. New apps default to Firestore.
Data model: Firestore keeps documents of up to 1 MiB in collections and subcollections; in Realtime Database, reading a node downloads everything under it, so the tree is kept flat
Queries: Firestore filters and sorts on several fields using indexes; Realtime Database orders or filters by one key per query, so you shape the tree around each read
Offline: both cache on mobile; Firestore's cache is on by default, Realtime Database needs setPersistenceEnabled(true)
Cost: Firestore charges per document read, write and delete; Realtime Database per GB stored and downloaded, so many tiny updates are cheap there
When Realtime Database: online presence with onDisconnect(), typing indicators, live cursors or game state: small values that change many times a second
The google_sign_in package gets a Google ID token, GoogleAuthProvider.credential wraps it, and signInWithCredential exchanges it for a Firebase user. The store-only failure is almost always a missing SHA fingerprint for the Play signing key.
google_sign_in 7.x: call GoogleSignIn.instance.initialize() once, then authenticate(); the account's authentication.idToken is all Firebase needs. A dismissed sheet throws GoogleSignInException with code canceled. On Android a missing SHA fingerprint or a wrong client id also shows up as canceled right after the account is picked, so log e.description and test a build installed from Play (internal testing)
Android fingerprints: Play re-signs your bundle with its own key, so register the SHA-1 and SHA-256 of the debug, upload and Play App Signing keys in the Firebase Android app and download a fresh google-services.json
iOS: add the REVERSED_CLIENT_ID from GoogleService-Info.plist as a URL scheme in Info.plist, or the flow cannot return to the app
Sign out of both: GoogleSignIn.instance.signOut() and FirebaseAuth.instance.signOut(), or the plugin keeps the previous Google session
Apple: App Store guideline 4.8 expects an equally private login option next to Google
authStateChanges() emits its first User? only after the SDK has restored the saved session from disk. Your first frame ran before that answer existed, so you treated null as "signed out" and showed the login screen.
The fix is three states instead of two: unknown, signed out, signed in. While the stream is still waiting, keep the splash and do not route anywhere.
Three streams: authStateChanges() fires on sign-in and sign-out; idTokenChanges() also on every token refresh; userChanges() also on profile edits such as updateDisplayName
Common mistake: reading FirebaseAuth.instance.currentUser right after initializeApp(): it can still be null while the restore is in flight
Persistence: on mobile the session is saved by default and survives restarts; only signOut() or a revoked refresh token ends it
flutterfire configure writes three config files, not one: lib/firebase_options.dart plus the native google-services.json (Android) and GoogleService-Info.plist (iOS). You swapped only the Dart file, so the native SDKs still point at prod.
Dart vs native: Firebase.initializeApp(options: ...) decides what Dart sees; pick the options by appFlavor, which --flavor sets. FCM, Crashlytics and Google sign-in start natively and read the plist and JSON
Android: Gradle prefers android/app/src/<flavor>/google-services.json over the one in android/app/, so one file per flavor folder is enough
iOS: there are no flavor folders: --ios-build-config=<configuration> adds a build phase that copies the plist written by --ios-out into the bundle for that Xcode build configuration, so run it once per configuration (Debug-staging, Release-staging, ...)
Separate projects: one Firebase project per environment, not several apps in one project, so data, rules and quotas never mix
Quick check: log Firebase.app().options.projectId on every non-release launch
signInWithCredential signed the user into a different account. The anonymous session had its own uid, Google sign-in created a new one, and every document keyed by the old uid is now orphaned.
The right call was currentUser!.linkWithCredential(googleCredential): the uid stays, the provider is added, isAnonymous becomes false, and every document, rule and Storage path keeps working.
When linking fails: if that Google account already has a Firebase user, you get credential-already-in-use and there is no automatic merge: grab the guest's ID token, sign in with e.credential, and let a Cloud Function verify that token and move the data
Anonymous accounts are not durable: a new device, cleared data or an Android reinstall means a new uid with no way back, and projects on Identity Platform can auto-delete them after 30 days
UX: ask for the upgrade while the value is on screen ("Sign in to keep your 12 saved games"), not as a wall at launch
Firestore has no joins, so a screen that must render in one round trip needs a document shaped for that screen: copy the three newest comments onto the post document itself.
Keep posts/{postId}/comments/{commentId} as the source of truth, and keep commentCount plus a recentComments array of three {id, authorName, text} maps on the post, updated by a Cloud Function trigger or a transaction whenever a comment is written.
Why not a query per post: 30 posts plus a comments query each is 31 round trips and up to 120 billed reads on every feed load
Copies go stale: when a user renames themselves, every copied authorName must change too; a trigger on users/{uid} fans the change out, or you accept old names on old posts
Bounded embeds only: a document is capped at 1 MiB and every read downloads all of it, so a list that grows without limit belongs in a subcollection
Cross-parent queries: "every comment this user wrote" is collectionGroup('comments').where('authorId', isEqualTo: uid), so subcollections do not lock you out (needs a collection-group index on authorId)
Rule of thumb: list the screens first, then design one document or one query per screen
You are seeing two snapshot events for one write: first the local one, with hasPendingWrites true and createdAt still null, then the server one with the real timestamp. The row jumps when your own sort treats the null differently from the final value.
Why it moves: FieldValue.serverTimestamp() reads as null in the local event. A Firestore orderBy("createdAt") already puts a pending row after all real ones, so a jump means your code re-sorts in Dart (null mapped to epoch or DateTime.now()) or a message with an earlier server time arrived meanwhile
Seeing the difference: doc.metadata.hasPendingWrites marks a local write; snapshots(includeMetadataChanges: true) also emits when only that flag flips, so you can swap the clock icon for a tick
Fix the sort: if you sort in Dart, write a client sentAt next to the server createdAt and sort by it while createdAt is null, or render pending rows in a fixed "sending" slot at the bottom
Offline: snap.metadata.isFromCache tells you the list may be stale, which is worth a small "offline" badge rather than a spinner
No. The Future from set() completes only when the server acknowledges the write, while the local cache applied it immediately; the real bug is awaiting a Firestore write in the UI.
Firestore is a local database that syncs, not a remote one with a cache. Build the UI on the cache and let the network confirm later.
Offline persistence: on by default on Android and iOS: a document cache (100 MB by default, Settings.cacheSizeBytes) plus a write queue that survives restarts and replays in order
Do instead: fire the write, let the listener drive the UI, and handle a rejection in the Future's error callback; waitForPendingWrites() is for the rare "is everything saved?"
Needs the network: runTransaction and Source.server reads fail offline; they are not queued
Rules: a rule-breaking write is accepted locally and rejected only at the server, so the row can vanish minutes later
It is a read-modify-write race: both clients read stock: 1, both pass the check, both write 0, and two orders exist for one item. The read, the check and both writes must happen in one runTransaction.
How a client transaction works: tx.get records the version it read; at commit the server rejects the transaction if any of those documents changed, and the SDK re-runs your function, up to five attempts by default (maxAttempts)
Keep the body pure: it may run several times, so no setState, no HTTP calls, no side effects; throw to abort, and nothing is written
Reads before writes: Firestore requires every tx.get to come before the first tx.set or tx.update
Offline: a transaction needs the server and fails without it; a WriteBatch is atomic too, has no reads, and is queued offline like any write
When it really matters: for payments or inventory, run it in a Cloud Function and deny client writes to stock in the rules, because a modified client can skip your transaction entirely
The query needs a composite index on userId, status and createdAt. Firestore builds single-field indexes automatically but refuses a query that needs a composite one, and the emulator does not enforce indexes at all, so it never warned you.
Read the error: the exception message contains a console link that creates exactly this index; building takes minutes, and the query keeps failing until it is ready
Fix it for good: keep indexes in firestore.indexes.json, deploy them with firebase deploy --only firestore:indexes, and run firebase firestore:indexes > firestore.indexes.json to pull ones someone created by clicking in the console
Missing fields: a document without createdAt silently drops out of any query ordered by it
Surface it: give the listener an onError, or a missing index just looks like an empty list
Rules run on Google's servers for every request and decide it from request.auth, the stored document (resource) and the incoming one (request.resource). The client enforces nothing: the SDK talks to Firestore directly and your config ships inside the binary.
The hidden button is UX: anyone holding your firebase_options.dart values can call the API with their own token; only rules and server code stand in the way
**resource vs request.resource**: check ownership on the stored data for update and delete, and validate the new data (types, sizes, ownerId == request.auth.uid, fields that must not change) on create and update
Rules are not filters: a query is checked as a whole: collection('notes').snapshots() is refused with permission-denied until it adds .where('ownerId', isEqualTo: uid), which proves every result is allowed
Showing the error: it arrives as a FirebaseException with code permission-denied; give every listener an onError, and cancel listeners before signOut(), or they all fail at once when request.auth turns null
Test them: rules are code: cover the allowed and the denied case against the emulator
A message with a notification payload is drawn by the operating system, and the OS deliberately does not draw it while your app is in the foreground; it arrives on FirebaseMessaging.onMessage as data for you to show yourself.
Foreground: onMessage fires and nothing is shown; draw it with flutter_local_notifications, or on iOS ask for the system banner with setForegroundNotificationPresentationOptions
Background: the system tray shows it, and onMessageOpenedApp fires when the user taps it
Terminated: getInitialMessage() returns the message that launched the app, once; skip it and a cold-start tap lands on the home screen
Background handler: a top-level function marked @pragma('vm:entry-point'), or release builds tree-shake it away; it runs in its own isolate, so it calls Firebase.initializeApp() and sees none of your app state
Data-only messages: draw nothing anywhere and only wake the handler; iOS throttles them, so do not rely on them for anything the user must see
Setup: requestPermission() (Android 13+ and iOS ask the user), send getToken() and every onTokenRefresh to your backend, and upload an APNs auth key in the Firebase console for iOS
The onMessage subscription is never cancelled. Every visit to the inbox adds one more listener that outlives its screen, so a single push runs every handler: one banner per visit, plus setState() called after dispose() from the dead states.
Why it piles up: FirebaseMessaging.onMessage is an app-wide broadcast stream; listen returns a StreamSubscription, and when you drop it, nothing ever calls cancel()
The leak: each handler closure holds its State, so every disposed inbox stays in memory for the life of the app
The fix: keep the subscription in a field and cancel it in dispose(); the same goes for onMessageOpenedApp, onTokenRefresh and a manual snapshots().listen
Better placement: foreground push handling is app-level: one listener set up at startup that updates shared state, and screens only read that state
Catching it: the cancel_subscriptions lint flags a stored subscription that is never cancelled (not a listen result that is dropped); a widget test that pumps the screen twice, emits one message and expects one banner catches the rest
Adding the plugin catches native crashes only. Dart errors reach Crashlytics only if you hand them over through FlutterError.onError and PlatformDispatcher.instance.onError.
Framework errors: FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError covers build, layout, paint and gesture callbacks
Everything else: PlatformDispatcher.instance.onError catches unawaited Futures and errors thrown outside the framework; isolates you spawn need their own addErrorListener
Readable reports: setCustomKey and log() for context, recordError(e, s, fatal: false) for handled failures, setUserIdentifier with an opaque id, never an email
Why still empty: reports upload on the next launch, not at crash time, and collection is usually off in debug via setCrashlyticsCollectionEnabled(!kDebugMode), which is exactly the build you tested
Obfuscated builds: stack traces stay unreadable until you upload the symbols from --split-debug-info with firebase crashlytics:symbols:upload
Log the events that answer product questions (funnel steps, feature use, purchases) with a few typed parameters, track screens explicitly, and never send personal data: no emails, names, phone numbers or free text.
Events: logEvent(name: 'checkout_started', parameters: {...}); names are up to 40 characters of letters, digits and underscores, and firebase_, google_ and ga_ prefixes are reserved. Prefer the recommended events (logPurchase, logLogin) so the standard reports understand them
Limits: up to 25 parameters per event and 500 distinct event names per app; a typo creates a separate event forever, so keep names as constants in one file
Screens: automatic tracking sees a single Flutter activity or view controller, so add FirebaseAnalyticsObserver to the navigator or call logScreenView yourself
Users: setUserId with an opaque id and setUserProperty for segments such as the plan; never an email
Consent: keep setAnalyticsCollectionEnabled(false) until the user agrees where the law requires it, and report the choice with setConsent
Seeing it: reports lag by up to a day; use DebugView with adb shell setprop debug.firebase.analytics.app <package> or the -FIRDebugEnabled launch argument on iOS
Never await a network fetch before the first frame. Set defaults, activate() whatever the previous run fetched, render, and let the new values arrive in the background.
Local and instant: setDefaults({...}) is what every user sees until a fetch succeeds, so every key you read must have a default; activate() touches no network and is safe to await at startup
Network: call fetchAndActivate() unawaited after runApp, and lower fetchTimeout from its 60-second default
Why a new value "does nothing": minimumFetchInterval defaults to 12 hours, so a fetch inside that window returns cached values; use Duration.zero in debug only
Realtime updates: onConfigUpdated tells you new values exist; they apply only after you call activate()
Common mistake: reading getBool inside build(): a mid-session activate() flips the screen under the user. Read values once into your state at a boundary, such as opening the screen
A callable forwards only an HttpsError (its code, message and details) to the client. Any other exception the function throws is flattened to internal with the message stripped (deliberately, so a stack trace never leaks to a public client).
Fix it on the server: throw HttpsError('failed-precondition', 'Card expired') for anything the user should read; let genuine bugs stay internal and find them in the function logs
On the client: you get a FirebaseFunctionsException with gRPC-style codes (unauthenticated, permission-denied, unavailable, internal); map it to your own failure types in the data layer so the vendor type never reaches the UI
Why only in production: the real failure comes from something the emulator lacks: a missing secret or env value, an IAM role, another region. Read the stack trace in Cloud Logging
Callable vs HTTP: httpsCallable attaches the Auth and App Check tokens for you; onRequest is for webhooks, where you verify everything yourself
Region: the client defaults to us-central1; a function deployed elsewhere needs FirebaseFunctions.instanceFor(region: 'europe-west1')
Retries: callables are not retried automatically, so send an idempotency key with anything that charges money
Firestore bills per document read, so look for code that reads far more documents than the screen shows: unbounded queries, a listener per list row, filtering or counting in Dart, and listeners that are leaked or re-created.
A listener costs one read per document when it attaches and one per changed document after that. A long-lived, bounded listener is cheap; each pattern below breaks one of those two words.
**No limit()**: snapshots() on a whole collection pays for every document and grows with the data, not with the users
A listener per row: 50 visible rows each with db.doc('users/...').snapshots() are 50 extra listeners; copy authorName into the row's own document instead
Filtering and counting in Dart: you pay for what the query matched, not for what you kept; filter with where, count with query.count().get(), billed per batch of up to 1,000 index entries rather than per document
**Streams created in build()**: an inline StreamBuilder(stream: query.snapshots()) re-subscribes on every rebuild. With the cache on (mobile) a quick re-attach bills only changed documents, but on web (cache off by default) or after a disconnect longer than 30 minutes it is billed like a brand-new query, and the screen flashes its loading state. Create the stream once per screen
Leaks: listeners still attached to screens the user left; use autoDispose or a real dispose()
Outside the app: a Cloud Function trigger that writes the document it listens to loops forever; set a budget alert and check the usage dashboard before reading code
asyncExpand does not move to the next auth event until the inner stream finishes, and a snapshots() stream never finishes. After a sign-out and a sign-in with another account, the screen keeps showing the first user's orders from a listener that is still attached.
Why it sticks: asyncExpand concatenates: it listens to the inner stream of one event and buffers later outer events until that stream is done. A Firestore listener ends only on cancel() or an error, so the null and the new User wait in the buffer forever
With strict rules: after sign-out the old listener usually fails with permission-denied, so the screen may flash an error first; do not rely on that to unstick the pipeline
The fix: switchMap from rxdart cancels the previous inner subscription on every auth event, so exactly one listener exists, scoped to the current uid; in Riverpod the same comes from a stream provider that ref.watches the auth provider
Catching it: a unit test with a fake auth StreamController: emit user A, then null, then user B, and expect B's orders and an onCancel on A's listener
App Check attests that a request came from your genuine, unmodified app on a real device. It answers "is this my app?", never "may this user do this?"; that stays the job of security rules.
How it works: FirebaseAppCheck.instance.activate(...) runs before any other product; the provider (Play Integrity on Android, App Attest on iOS) gives a verdict that is exchanged for a short-lived token, which every Firestore, Storage and Functions call carries automatically
Rollout: ship the SDK first and watch the console metrics until nearly all traffic is verified; enforce earlier and every older installed version stops working with no way to fix it
Debug builds: simulators and CI cannot attest: use the debug providers, register the printed debug token in the console, and never ship that provider
Replay: a normal token is reusable until it expires; for sensitive callables, enable token consumption on the function and call it with HttpsCallableOptions(limitedUseAppCheckToken: true); on your own backend send getLimitedUseToken()
What it does not do: it authorises nothing, so a signed-in user still reads whatever the rules allow, and a hooked runtime on a rooted device can still send requests
Three layers: a repository interface so widget tests never see Firebase at all, the Emulator Suite for code that really talks to Firestore, and the JavaScript rules-testing library for the rules themselves.
Widget and unit tests: Firebase.initializeApp() needs platform channels, so a widget test that reaches it throws. Keep DocumentSnapshot, Timestamp and Query behind a repository and inject a fake; fake_cloud_firestore is an in-memory stand-in, its rules support is partial (single-document operations only), so test real rules in the emulator
Emulator Suite: firebase emulators:start --only auth,firestore,functions, then useFirestoreEmulator and useAuthEmulator right after initializeApp. The plugins map localhost to 10.0.2.2 on an Android emulator; a physical device needs your machine's LAN address
Never touch prod: use a project id that starts with demo-: the emulators run fully offline and any product you forgot to point at an emulator fails instead of reaching a real project
In CI: firebase emulators:exec starts, seeds, runs the tests and tears down in one command
Rules: there is no Dart harness; test them with @firebase/rules-unit-testing, asserting both the allowed and the denied case
Firestore for realtime, offline-first apps with per-screen reads; Supabase when the data is relational and you need SQL; your own backend when residency, an existing server team or custom logic decides, and keep the vendor SDK out of your widgets, because that is the whole exit path.
Firestore: realtime updates and an offline cache the SDK maintains for you: most of an offline-first app for free. It bills per document read, has no joins, and reporting happens elsewhere (usually a BigQuery export)
Supabase: Postgres: joins, views, transactions, SQL reporting, and row-level security policies instead of rules. Billed by plan and compute size rather than per read, and there is no built-in offline cache, so you build sync yourself
Your own backend: when data residency, an existing server team or logic that rules cannot express decides it; you then own auth, push, storage and operations
What really locks you in: the client SDK leaking upward: a DocumentSnapshot in a widget, a Timestamp in a model. Rules and triggers do not port; user accounts export fairly easily
Rule of thumb: one repository interface per aggregate and domain models with no vendor types; that is the difference between a migration and a rewrite