Blog / Read cost

The complete guide to Firebase reads (why your bill is so high)

Your Firebase bill is high because Firestore charges you per document read, and one feature — usually an unbounded listener, an N+1 pattern, a re-rendering tile, or an overnight job — is issuing far more reads than you think. The console shows one total with no names on it. This guide explains exactly how Firestore reads are billed, what makes them spike, and how to find and cut the expensive ones for good.

  • Firestore bills per document read, not per query — every document a query returns is one read, and a query that returns nothing still costs one.
  • Bills spike from a small number of causes: unbounded listeners, N+1 reads, fan-out, a re-rendering tile, an ingest path, and overnight machine jobs. Usually one of them dwarfs the rest.
  • The Firebase console and Query Insights show you totals and slow queries — never which feature or which user spent the reads. That attribution is the whole job.
  • To fix it: group reads by the feature and the person that caused them, cut the biggest bucket first, then prove it in reads per hour before and after — not next month's invoice.
  • Measure with a meter that never costs you a read (counts the result already in hand, in memory). Buckets is the open-source, MIT collector that does exactly this, and attributes every read to the function and the user.

Definitions used in this guide

Read

A single document Firestore returns and bills you for. Query returns 100 documents, that is 100 reads; a query that returns nothing is billed a minimum of one.

Listener (onSnapshot)

A realtime subscription. It bills the documents it delivers — the whole result set on attach, then each changed document on every update.

Aggregation (count/sum/average)

A server-side count that reads index entries instead of documents. Cheaper than reading every match, but billed at roughly one read per 1,000 entries.

Read attribution

Connecting each read back to the feature (the function or route) and the user that caused it, instead of one undivided total on the invoice.

Actor

Who caused a read — a specific user, or machine for work no person triggered (a cron, a queue consumer), still attributable by tenant.

Bucket

A named read path — a handler, route, or job — so its reads group under one label. The unit you rank, watch, and cut.

Why is my Firebase bill so high? The short answer

Your Firebase bill is high because Firestore charges you for every document it reads, and somewhere in your app a single feature is reading far more documents than you realise. It is rarely the feature you would guess. In practice the culprit is one of a handful of patterns — a realtime listener with no bound on it, a list that fetches one extra document per row, a dashboard tile that re-queries every time it renders, or a background job that quietly re-scans a whole collection every few minutes overnight. Any one of these can out-read your entire user base and never show up in your product analytics.

The reason it is so hard to see is structural. The Firebase console gives you exactly one number — reads today — with no memory of what spent them. It has no concept of your features, your routes, or your users, so it cannot tell you that 80% of a 9-million-read day came from one map tile, or that the overnight wave is a reconcile job rather than customers. The bill is a total with no names on it. Until you put names on your reads — grouping them by the code path and the person that caused them — the expensive path stays hidden inside the total, and you are left bisecting deploys and staring at graphs at 4am.

The rest of this guide is the complete version of that answer: precisely how Firestore billing works, every pattern that makes reads spike, how to diagnose which feature and which user is responsible, what Google's own Query Insights can and cannot tell you, a step-by-step reduction playbook with before-and-after numbers, how to prove a fix actually worked, and how all of this generalises to MongoDB, Postgres, and Supabase. We built read-cost attribution into our own product and open-sourced the collector, so the examples are first-hand, not theoretical.

How Firestore and Firebase billing actually works

Firestore bills you for operations, not bytes. The three operations that appear on your bill are document reads, document writes, and document deletes. Storage and network egress are billed separately and are usually a rounding error next to reads for a read-heavy app. The single most important fact to internalise is that the unit is the document: it does not matter how clever or simple your query is — if it returns 500 documents, that is 500 reads, and you pay for all 500.

This is different from a traditional SQL database, where you are billed for a server that runs whatever queries you throw at it. In Firestore's serverless model there is no instance to size — you pay per operation, so cost scales directly and linearly with how many documents your code touches. That is a gift when traffic is low (you pay almost nothing) and a trap when one path starts touching documents in a loop, because nothing caps it and nothing warns you.

Reads vs writes vs deletes

All three are counted per document, but reads dominate almost every bill because apps read far more than they write. A social feed might write one post and then serve it to ten thousand readers; an analytics dashboard might read tens of thousands of documents to render a single chart and never write at all. The table below is the billing model at a glance.

Firestore operations and how each is billed
OperationWhat triggers itHow it is billed
Readget(), getDocs(), a query, an onSnapshot fireOne per document returned
Writeset(), add(), update()One per document written
Deletedelete()One per document deleted
StorageData at restPer GiB stored (billed separately)
EgressData leaving the regionPer GiB transferred (billed separately)

The free tier, and where Blaze begins

Firebase's free Spark plan includes a daily quota that resets every 24 hours: 50,000 document reads, 20,000 writes, and 20,000 deletes per day, plus 1 GiB of stored data. This is generous enough that a hobby project or an early prototype can run for free indefinitely — which is exactly why the first serious bill is such a shock. The day you flip to the pay-as-you-go Blaze plan (required for most production features), the daily free allowance still applies, but everything above it is billed. A feature that was comfortably under 50,000 reads a day in testing can cross into millions in production without a single line of code changing, purely because real users and real data volume arrived.

Two things follow from this. First, the free tier hides cost problems until you scale — the expensive pattern was always there, it just fit under the quota. Second, because there is no per-instance ceiling, there is no natural backstop: a runaway loop bills until you notice. If you want the longer version of getting ahead of this before it bites, we wrote a dedicated guide on how to avoid a surprise database bill before you scale.

What exactly counts as a read in Firestore?

This is where most surprise bills are born, because several common operations count as reads in ways that are not obvious. The rule to remember: Firestore bills the documents delivered to you, plus a minimum of one read for any query that runs at all. Here is the complete counting model, matching how Firestore actually bills and how our open-source collector counts to reconcile against the invoice.

The Firestore read counting model — what each operation costs
OperationCounted as
Query returning N documentsN reads
Query returning no results1 read (the minimum-one-read rule)
doc.get() on a single document1 read
getAll(...refs) / batched lookups1 read per reference
onSnapshot fire (server or browser)The documents that fire delivered
count() / aggregation~1 read per 1,000 index entries matched
Write / delete1 each (not a read)

The minimum-one-read rule

Firestore applies a minimum charge of one document read for every query you run, even if it matches zero documents. This sounds trivial until you meet a page that fires twenty always-empty existence checks on load — "does this user have a pending invite?", "is there a draft?", "any unread notifications?" — each returning nothing and each costing a read. Multiply by every page view and the empty queries alone become a line item. The lesson: an empty result is not a free result.

Cached reads are usually free — but not the ones that matter

The client SDK keeps a local cache, and a read served entirely from that cache (for example, with offline persistence enabled) does not hit the server and is not billed. This is genuinely helpful, but it lulls people into thinking reads are cheaper than they are, because the expensive reads — the initial load of a listener, a fresh query, a server-side admin read — bypass the cache by design. Do not assume caching is protecting you; the reads that build your bill are precisely the ones the cache does not serve.

Reads happen wherever your code runs

A read is billed to your project no matter where it is issued — your backend via the Admin SDK, or a user's browser or phone via the client SDK. Both count identically per document, but they show up from different surfaces, and that split is the source of a lot of confusion we will come back to in server reads vs browser reads.

Firestore read pricing explained

Individually, Firestore reads are cheap. At current published rates a document read costs on the order of $0.06 per 100,000 reads (writes run higher, around $0.18 per 100,000; deletes lower, around $0.02 per 100,000). We are labelling these as illustrative on purpose: Firebase's exact prices vary by region and change over time, so always confirm the current numbers on Firebase's own pricing page before you do the math on your own bill. The structure — billed in bulk, per operation, per document — is what stays true.

Because a read is so cheap in isolation, cost is entirely a volume story. The table below is illustrative arithmetic to build intuition for how quickly per-document billing compounds. Treat the dollar figures as rough and check live pricing for your region.

Illustrative: how read volume becomes a bill (verify live pricing)
Reads per dayReads per monthIllustrative monthly cost (~$0.06/100k)
50,000 (free-tier ceiling)~1.5M$0 — inside the free quota
1,000,000~30M~$18
10,000,000~300M~$180
100,000,000~3B~$1,800

The takeaway from the arithmetic is blunt: you never notice the price of a read, you notice the count. A single tile issuing 15,000 reads per render — a real number from our own dogfooding, described below — sounds harmless until you multiply it by every render, every user, every day. The job is never to make an individual read cheaper; it is to find and delete the counts you did not mean to spend. That is why the entire discipline is measurement first, optimisation second.

What actually makes a Firestore bill spike

Read-cost surprises are not random. They come from a short, recognisable list of patterns, and on any given app one of them is usually responsible for the majority of the bill. Learn to recognise these six and you can diagnose most spikes on sight.

1. Unbounded listeners

An onSnapshot listener on a query with no limit() reads the entire result set on attach, and re-delivers documents on every change. Point one at a collection that grows — messages, events, activity — and its cost grows with the collection, forever. Worse, a listener that is torn down and re-attached (which a re-rendering front end does constantly) pays the full initial read again each time. An unbounded, frequently-remounted listener is the single most common cause of a runaway Firestore bill.

2. N+1 reads

You fetch a list of N documents, then loop and fetch one related document per item — the author of each post, the profile behind each comment. That is N+1 reads for one screen, and it scales with the size of the list. A feed of 50 posts that each look up an author is 50 extra reads per load, per user. N+1 is invisible in code review because each individual read looks reasonable; it only reveals itself in aggregate.

3. Fan-out

One action triggers reads across many documents or collections. A Cloud Function that fires on a write and then reads twenty related documents to update them; a "load dashboard" action that quietly queries eight collections; a notification system that reads every follower on each post. Fan-out multiplies a single user action into dozens or hundreds of reads, and because the fan-out lives in shared code it is easy to forget it is there.

4. A tile re-rendering

A dashboard widget re-runs its query on every render or refresh. If the component re-renders on unrelated state changes, or refreshes on a short interval, each render is a fresh set of reads. This is the pattern we hit on our own product: one tile was quietly issuing about 15,000 reads per render and hid in plain sight for a day. It was not a slow query — each render was fast — it was a frequent one, and frequency is what bills you.

5. The ingest path

A background pipeline re-reads a collection to process or transform it — an indexer, an export, a sync. Because no user is involved, it never appears in product analytics, and because it is "just infrastructure" nobody instruments it. On our own platform the ingest path was 1.4 million reads a day, the majority of the entire bill, and completely invisible until we metered it. The ingest path is the classic blind spot: the biggest cost, attached to no feature, watched by no one.

6. Machine and cron reads overnight

Scheduled jobs, queue consumers, and database triggers run on timers, not on user activity — so they read steadily through the night while your product analytics show zero traffic. If you see a flat, rhythmic read curve at 3am with nobody on the app, that is a machine, not a customer. These reads are some of the easiest to cut because nobody is waiting on them, but you have to be able to see them first. We wrote a focused piece on exactly this signature: why your database reads spike overnight when no users are online.

The six classic causes — signature and first move
CauseSignatureFirst move
Unbounded listenerCost tracks a growing collection; spikes on navigationAdd limit(); stop remounting the listener
N+1 readsReads scale with list lengthBatch the lookups or denormalise the field
Fan-outOne action, dozens of readsPrecompute or cache the fanned-out data
Re-rendering tileFast query, fired constantlyMemoise; query once, not per render
Ingest pathHuge, steady, tied to no userRead once and stream; stop re-scanning
Machine / cronFlat overnight curve, no trafficWiden the interval; read deltas, not the whole set

Notice that in every row, the fix is cheap and obvious once you know which pattern it is. The hard part is never the fix — it is the attribution. That is the theme of the rest of this guide.

Firestore listener cost: how onSnapshot bills

Realtime listeners are Firestore's best feature and its most misunderstood cost. Here is the exact model: when you attach an onSnapshot listener, you are billed one read for every document in the initial result set. After that, each time the query's result changes, you are billed for the documents that changed and are re-delivered — not the whole set again, just the deltas. Keeping the listener open costs nothing per unit of time; you only pay for documents delivered.

That model is efficient if the listener is stable and bounded. It becomes expensive in two specific ways. First, an unbounded initial set: attach a listener to a 10,000-document collection and you pay 10,000 reads on attach, every attach. Second — and this is the one that catches people — detaching and re-attaching re-pays the initial read in full. A React component that subscribes in an effect and re-subscribes whenever a dependency changes, or a page that tears the listener down on navigation and rebuilds it on the way back, pays the full initial cost each cycle. A user bouncing between two screens can trigger the same expensive initial read dozens of times a session.

The practical rules that follow:

  • Always bound a listener with limit() unless you genuinely need every document — a live feed needs the latest 20, not all history.
  • Attach listeners at a stable level in your component tree and keep them mounted; do not let them churn on every render.
  • In React, be deliberate about effect dependencies and Strict Mode double-mounting, which can double a listener's first fire in development (production builds do not double-invoke, so your prod numbers are exact).
  • Prefer a one-time get() over a listener for data that does not actually need to be live.

The measurement subtlety with listeners is that a listener that lives in the browser bills your project directly and never touches your server, so a server-side view of your reads is blind to it. We learned this the hard way dogfooding our own dashboard, where 94% of our reads were browser-side and a server-only instrument saw none of them. Any honest accounting of listener cost has to include the browser.

count() and aggregation queries: cheaper, not free

Firestore's aggregation queries — count(), sum(), and average() — exist precisely to save you from reading every matching document just to compute a number. Before they existed, "how many active users?" meant reading every active-user document. Now the server computes it from index entries and returns one number. That is a large saving, and you should use them.

But they are not free. An aggregation is billed at roughly one document read per 1,000 index entries it scans, with a minimum of one read. So counting a collection of 50,000 matching documents costs about 50 reads instead of 50,000 — excellent — but calling that count() on every page load, for every user, still adds up, and calling it on a collection of tens of millions is a real cost of its own. The rule: use aggregations to replace document scans, but treat a frequently-called count() on a large collection as a cost worth attributing like any other, and cache the result when the exact live number is not essential.

A meter that respects the counting model estimates aggregation cost honestly as ceil(matched / 1000) — because Firestore does not expose the exact index-entry count, an honest estimate is the best you can do, and it reconciles closely against the invoice.

How to diagnose a high Firebase bill: which feature, which user?

Diagnosis is the whole game, and it comes down to answering two questions the console cannot: which feature spent the reads, and which user (or machine) caused them. Get those two axes and the fix is almost always self-evident. Miss them and you are guessing.

The method is to group reads by cause. Instead of one total, you want a ranked list: this handler spent 765,000 reads today, that job spent 1.2 million, this route spent 3,000. The moment the reads are grouped by the part of the product that issued them, the expensive path stops being theoretical — one bar dwarfs the rest, and that bar is your target. This is the core idea behind our guide on why Firestore reads cost money and you can't see where: a bill is a total with no memory of what spent it, so you add the missing dimension — a name on every read.

The two axes that matter

  • The function (what). Reads should attribute to the operation that spent them — the handler or route, not the page. One page can fire six operations and only one is the monster; blaming the page hides which of the six to fix.
  • The user (who). Splitting reads by the person behind the request tells you instantly whether a spike is a real customer, a specific power user, or a background job. Work no person caused attributes to machine, still carrying the tenant it ran for — so you keep per-customer cost on background work and only drop the person dimension.

The combination — reads by user × function, what we call the cross-match — is the thing no query profiler can produce, because a profiler has no idea who your users are. It can show you a slow or frequent query; it cannot show you whose it was. Once you can read "user_847 spent 38,000 reads in the analytics-refresh function" or "the machine actor spent 1.2M in nightly-reconcile", the conversation stops being "the bill went up" and becomes "this job, on the server, doubled — here is the fix."

Let your database rank the suspects first

Before you instrument anything, your datastore already ranks its own most expensive reads. On Firestore that is Query Insights (next section). Use it to get a cost-sorted shortlist of queries, then trace each heavy query to the code path that issues it — that is your tagging to-do list, sorted by cost, and it means you name your single most expensive read first.

Query Insights: what it tells you, and the one thing it can't

Firestore's Query Insights (in the Firebase console under Firestore) is genuinely useful and underused. It ranks your queries by read volume, shows the collection each one hits, and tells you how many documents each scan touches. That is exactly the shortlist you want when you are hunting a spike: it points you at the heaviest queries so you are not guessing which to investigate.

Here is precisely where it stops. Query Insights operates on queries, and it has no concept of your application — no features, no routes, no users. So there are three questions it structurally cannot answer:

  • Whose read was it? Query Insights cannot tell you which of your users ran a query, because Firestore does not know who your users are — you do. The identity lives in your session, not in the database's view of a query.
  • Which feature issued it? The same query text can be fired from six different code paths. Query Insights sees the query; it cannot tell you which handler, route, or job sent it.
  • Where did it run? A query fired from a user's browser and the same query fired from your server look the same to the console, but they need fixing in different places.

This is not a criticism of the tool — it is a boundary. A database-side profiler can only see what the database sees. The attribution to your features and your users has to be added at the point the reads happen, in your code, from the identity you already have. That is the gap the rest of this guide fills, and the specific gap the Buckets collector was built to close.

Query Insights vs feature-and-user read attribution
QuestionQuery InsightsRead attribution
How many reads today?Yes (console total)Same total, reconciled
Which query is heaviest?Yes, rankedYes, ranked by feature
Which feature spent them?NoGrouped by bucket
Which user caused them?NoGrouped by actor
Server or browser?NoStamped per environment

Server reads vs browser reads: install where you read

With Firestore, your app usually reads from two places at once: your server (the Admin SDK, in your backend and Cloud Functions) and your users' browsers and devices (the client SDK — live onSnapshot listeners and direct getDocs/getDoc calls that bill straight to your project and never touch your server). Your bill is the sum of both.

This matters enormously for diagnosis, because a collector only sees reads on the surface it runs on. Instrument only your server and you see server reads — which, for many apps, are the minority. The Firebase console total will be much larger than your server instrumentation says, and the gap is the browser. On our own dashboard the browser was 94% of reads; a server-only view was effectively blind. The fix is not clever — it is to measure both surfaces, with a collector on each, reporting into one place so server and browser reads sit side by side and you can tell which environment a problem bucket bleeds from.

The practical implication when you go hunting: if your server logs say your reads are modest but the bill says otherwise, the reads are in the browser. Live listeners on client screens are the usual suspects — they are easy to leave unbounded and easy to remount, and they are completely outside your backend's field of view.

A real example: the tile that hid in plain sight

It is worth grounding all of this in what actually happened to us, because the shape is so typical. We shipped a feature. A week later our own database read volume was several times higher, and the Firebase console showed us the same single number it shows everyone: reads today, no breakdown. We did what everyone does — added logs, bisected deploys, stared at graphs late at night — and still could not point at the cause with confidence.

When we finally sat down and instrumented our read sites by hand, we found a dashboard tile that was quietly issuing about 15,000 reads per render. It had been hiding in plain sight for a full day. It was not a slow query — every render completed fast — it was a frequent one, fired far more often than we assumed, and frequency is what the invoice charges for. That was the visible half of the lesson.

The invisible half was worse. Even after we hand-instrumented the paths we were looking at, we still missed the path that mattered most: the ingest pipeline, running around 1.4 million reads a day — the majority of our entire bill — attached to no user-facing feature and therefore watched by no one. The uncomfortable conclusion is that humans instrument what they are looking at and miss the path that matters. Manual cost tracking does not fail loudly; it fails silently, and you find out on the invoice.

That is the exact experience that convinced us the trap has to be set once, at the SDK, so every read is caught by construction — the cron you forgot, the trigger you never think about, the ingest job nobody owns — rather than at the call sites you happen to remember. It is also why we insist the fix and the measurement live on the same surface: we could only claim the ingest path was fixed once we could watch that specific bucket fall, not the total. Your app will have its own version of both halves — a loud one you can find by looking, and a quiet one you can only find by metering the paths you would never think to look at.

The step-by-step Firestore read-reduction playbook

Here is the loop we use, in order. It is deliberately measurement-first: you never optimise a read you have not attributed, because you will optimise the wrong thing. If you want the version focused purely on cutting cost without touching your architecture, we have a companion guide on how to reduce Firestore read costs without rearchitecting your app.

  1. Establish the baseline. Read the reads-per-day total from the Firebase console. That is the number you are trying to move, and the number that reconciles everything else.
  2. Get a cost-sorted shortlist. Open Query Insights and note the heaviest queries and the collections they hit. This is where to point your attention first.
  3. Attribute reads to features. Instrument your read paths so every read groups under a named bucket — the handler, route, or job that issued it. Do this at the operation boundary, not query by query, so one line names a whole route.
  4. Attribute reads to users. Stamp the actor — the user id you already have in session — at the request boundary, so reads split by who caused them and background work lands under machine.
  5. Rank and pick the biggest bucket. Sort features by read load. The largest bar is almost always where the cheapest win is. Ignore everything else for now.
  6. Identify the pattern. Match the biggest bucket to one of the six causes above — unbounded listener, N+1, fan-out, re-rendering tile, ingest path, or machine job. The pattern dictates the fix.
  7. Apply the targeted fix. Add a limit(), batch the N+1 into a single getAll, denormalise a hot field, memoise the tile, widen a cron interval, or read deltas instead of the whole set.
  8. Verify in reads per hour. Confirm the specific bucket moved down within the hour — not that the total drifted. (Full method in the prove-the-fix section.)
  9. Repeat on the next-biggest. Cost reduction is iterative: fix the monster, the next one surfaces, tag and fix that. Coarse to fine, one ship at a time.

A worked before/after (illustrative)

To make the loop concrete — the numbers here are illustrative, chosen to show the shape, not real customer data. Suppose your baseline is 9.6 million reads a day and you have no idea where they go. You attribute reads by feature and the ranking comes back:

Illustrative: reads grouped by feature reveal the monster
Feature (bucket)Reads / day (before)Share
nightly-reconcile (machine)1,400,000~15%
dashboard-pulse-tile6,900,000~72%
home-feed820,000~9%
everything else480,000~5%

The dashboard pulse tile is 72% of the bill — a re-rendering-tile pattern, firing its full query on every render. You memoise it so it queries once per data change instead of once per render. The next day:

Illustrative: after fixing the one biggest bucket
Feature (bucket)Reads / day (after)Change
dashboard-pulse-tile390,000−94%
Total~3,090,000−68%

The two levers: denormalise, or cache

Once you know which bucket to cut, nearly every Firestore read fix is one of two moves, and it helps to name them. Denormalisation copies the data you keep re-fetching to where you read it, so the extra read disappears: instead of reading a post and then reading its author, you store the author's display name and avatar on the post document at write time, and the feed renders from one read per post instead of two. You trade a little write cost and some duplication for a permanent cut in read volume — usually a good trade, because reads dwarf writes on a read-heavy app.

Caching keeps a recent answer in memory (or in a cheap store) so repeat requests skip the database entirely: a count() of total users that changes slowly does not need to run on every page load; compute it every few minutes and serve the cached number. The rule of thumb is to denormalise data that is read together and cache data that is read repeatedly. Reach for a rearchitecture only when neither lever fits — and most of the time one of them does, which is why a bill can usually be cut without a rewrite.

One targeted fix on one attributed bucket took two-thirds off the bill — and you can see it did, on the specific feature, the same day. That is the entire value of attribution: it turns "the database bill is high" into a one-line task with a verifiable result.

Serverless flush pitfalls: why counts vanish on a freeze

If your reads happen inside serverless functions — Cloud Functions, Lambda, Vercel, Cloud Run, Cloudflare Workers — there is a specific trap that makes cost measurement lie to you, and it is worth understanding even if you never use our tools, because it explains a whole class of "my numbers don't match the bill" confusion.

Serverless platforms freeze the container the instant your handler returns — that is how they save you money, by stopping the CPU the moment there is no work. But an in-memory meter that batches counts and flushes them on a short timer relies on that timer firing. On a frozen container the timer never fires, so the counts buffered since the last flush are paused with the container and never shipped. The reads happened, your datastore billed them, but your dashboard never saw the window. It under-reports, and it is worst exactly when traffic is sparse — overnight, low-volume endpoints — making a bill look smaller than it is right when you are trying to explain why it is large.

The fix is to flush before the function returns, in a finally, using the split-second of CPU you have already paid for on that invocation — so nothing is kept awake and there is no added cost. Reach for it at every serverless entry point: HTTP and callable functions, queue and stream consumers, scheduled jobs, database triggers. On an always-on process (a long-running container or classic Node server) you do not need it — the timer ships your counts. This is the single integration detail that makes "catch essentially all of your reads" hold true on infrastructure that sleeps, and we cover the attribution side of it in depth in how to attribute database read costs to features in a serverless app.

How to prove a Firestore fix actually worked

The mistake here is comparing day totals. Day totals move for a dozen unrelated reasons — traffic, a new feature, a marketing spike — so a day-over-day drop does not prove your change did anything, and next month's invoice arrives far too late to be a feedback loop. The honest test has two properties: hourly grain and per-feature attribution.

  • Hourly, not daily. Keep reads at hourly resolution so you can ship a change and watch it land the same hour. A fix is not a day, it is a moment — you want to compare the hours immediately before the deploy against the hours immediately after.
  • Per-feature, not total. Confirm that the specific bucket you changed moved down. If the total fell but your bucket did not, something else changed and your fix did nothing. If your bucket fell and the total followed, you have proof.

Concretely: stamp the moment you shipped, split the timeline there, and read the verdict in reads per hour — for example, a path going from 1,240 reads/hour before to 310 after is a real, attributable −75% you can defend, and it settles as more post-fix hours land. That before/after loop, done properly, is the difference between "I think that helped" and "that helped, here is the number." We wrote the full method up separately in how to prove a database cost fix actually worked, before and after.

Metering your reads without a tool that costs you reads

There is a nasty irony lurking in read-cost tooling: the naive way to measure your reads is to query them — run an EXPLAIN, poll a stats collection, scan your own logs — and every one of those is itself a read. A cost tool that costs reads to run is worse than none, because it grows the very number it claims to watch. The property to insist on is that the meter counts the result already in hand, in memory, and never issues a query of its own. We wrote a dedicated piece on this exact trap: how to measure database read cost without a tool that costs you reads.

This is the problem we built Buckets to solve, and open-sourced under MIT so it can be a free, commodity primitive. Buckets is a read-cost collector that attributes every read to the function that spent it and the user who triggered it, from the identity you already have — with no blind spots, and without ever becoming a cost itself. Three ideas make it work, and they are worth understanding whether or not you use it:

  • Tag once at the edge. Set the actor (the user) once when a request arrives; it propagates through every async fan-out automatically via AsyncLocalStorage, so you do not thread it through your call stack.
  • Trap at the SDK, not the call site. Buckets patches the Firestore driver's read methods once, at startup. From then on every read on every path is counted — the ingest job, the cron, the trigger, the path you forgot — so there are no blind spots by construction. This is the part hand-rolled instrumentation always gets wrong, because humans instrument what they are looking at and miss the path that matters.
  • Count in memory, write rarely. Counts accumulate in-process and flush a tiny summary about once a minute, regardless of whether you served ten operations or ten million. The monitor never becomes the thing it monitors.

It is safe to put on your read path because every wrapper calls the real method first, counts inside a try/catch that cannot throw into your code, and always returns the real result untouched — it physically cannot break a read, change a result, or add latency beyond a single in-memory increment. And it counts to the model in this guide: N reads for N documents, one for an empty query, the delivered docs for each listener fire, ceil(matched/1000) for an aggregation — so its numbers reconcile against your Firebase invoice rather than drifting from it. You can run it with no account and read the numbers straight off a local file, or point it at Crossdeck to stitch server and browser into one view and get paged in Slack before the invoice arrives. Either way, the collector is yours, free, forever.

How this generalises: MongoDB, Postgres, and Supabase

Firestore is the sharpest version of this problem because it bills per document so directly, but the pattern — and the cure — is universal. Every datastore charges you for read load in some unit, and on every one of them the winning move is the same: attribute the load to the feature and the user, cut the biggest bucket, prove it before and after. Only the unit changes.

Read cost across datastores — the unit changes, the method doesn't
DatastoreWhat you pay forThe read unit to attribute
Firestore / FirebasePer document operationDocuments read
MongoDB (incl. Atlas)Compute + I/ODocuments returned
Postgres (Supabase, Neon, RDS)Compute — instance size × hoursRows read (the load that sizes your instance)

On Postgres, Supabase, Neon, and RDS you are not billed per row — you are billed for compute, an instance sized by its heaviest load. So "rows read per feature" is not your invoice; it is the read load that decides how big an instance you need and where to optimise. The diagnosis tools differ (pg_stat_statements instead of Query Insights) but the blind spot is identical: the database ranks queries, never your features or users. We cover both angles in why your Supabase bill is climbing and how to find the queries behind it and how to find the most expensive queries in Postgres, Neon, and RDS.

On MongoDB, a query is counted as the documents it returns, attributed to the feature, with the same trap-at-the-driver model — observe-only, reading the result already in hand, never running an explain() or a profiler scan that would itself become a read monster. The feature-level view is exactly what you need to find the one collection scan driving your load, which we walk through in how to find which feature is driving your MongoDB read load.

The reason one collector can cover all three is that it does not really measure "reads" — it measures resource units, the raw quantity of whatever a service charges for, kept on its own line and never summed across sources (a document read and a query-millisecond are different things and are never added together). Firestore just happens to be the first place most teams find the leak, because per-document billing makes the leak loud.

The Firebase read-cost checklist

Everything above, compressed to a checklist you can run against your own app today:

  • Read your baseline reads-per-day from the Firebase console, and know your 50,000/day free ceiling.
  • Open Query Insights and note the heaviest queries and their collections.
  • Bound every onSnapshot listener with limit() unless you truly need all documents.
  • Stop listeners from remounting on every render; attach them at a stable level and keep them mounted.
  • Hunt N+1 patterns — any list that does a per-item lookup — and batch or denormalise them.
  • Find fan-out in Cloud Functions and shared "load everything" actions; precompute or cache.
  • Memoise dashboard tiles so they query per data change, not per render.
  • Meter the ingest path and any background job — the biggest cost usually hides there, tied to no user.
  • Look for a flat overnight read curve; that is a machine, and machine reads are the easiest to cut.
  • Measure server and browser reads — the majority are often browser-side and invisible to your backend.
  • Use a meter that never costs a read: counts in memory, issues no query of its own.
  • On serverless, flush before the handler returns so counts don't vanish on the freeze.
  • Prove every fix in reads-per-hour on the specific feature, before and after — not day totals.

Frequently asked questions

Why is my Firebase bill so high?

Almost always because Firestore bills per document read and one feature is issuing far more reads than you think — usually an unbounded onSnapshot listener, an N+1 read pattern, a dashboard tile re-querying on every render, or a background job re-scanning a collection. The Firebase console shows one total with no breakdown by feature or user, so the expensive path stays hidden until you attribute reads to the code and the person that caused them.

What counts as a read in Firestore?

Every document a query returns is one read. A doc.get() is one read. A query that returns no results is still billed one read. Each onSnapshot listener fire is billed as the documents it delivers. A count() aggregation is billed roughly one read per 1,000 index entries matched. Writes and deletes are billed separately, one each.

Does an empty Firestore query cost a read?

Yes. Firestore applies a minimum charge of one document read for every query, even if it matches zero documents. A page that fires many always-empty queries still runs up reads.

How much does a Firestore read cost?

Firestore bills document reads in bulk — at current published rates roughly $0.06 per 100,000 reads, though the exact price varies by region and changes over time, so verify against the Firebase pricing page. The free Spark tier includes 50,000 reads per day. Reads are cheap individually; bills get large because one misbehaving feature can issue millions a day.

Is the Firestore count() aggregation free?

No. count(), sum(), and average() are far cheaper than reading every matching document, but they are still billed — roughly one document read per 1,000 index entries scanned, minimum one. Calling count() on a huge collection on every page load is not free.

Do Firestore listeners (onSnapshot) cost money?

Yes. A listener bills reads for the documents it delivers: the initial attach reads every matching document, and each later change delivers and bills the changed documents. Keeping a listener open does not cost per second, but detaching and re-attaching it — which a re-rendering UI does constantly — re-reads the whole result set every time.

Why do my Firestore reads spike overnight when no users are online?

A steady wave of reads with nobody using the app is a machine, not a customer — a scheduled job, cron, queue consumer, or database trigger re-reading a collection on a timer. It has no person behind it, so it never appears in user analytics, but it bills like everything else. A flat, rhythmic overnight read curve is the signature of automated work you have not attributed yet.

Does Query Insights tell me which user caused a read?

No. Query Insights ranks your most expensive queries by read volume, shows the collection each hits, and how many documents each scan touches — a useful, cost-sorted to-do list. But it has no concept of your users or your features, so it cannot tell you which customer ran a query or which product operation issued it. That attribution has to be added at the point the reads happen.

Do reads from the browser cost differently from server reads?

They cost the same per document, but they are billed to your project from two different places. Server code (firebase-admin) reads on your backend; users' browsers read directly via the client SDK — onSnapshot listeners and getDocs calls that never touch your server. Your bill is the sum of both, and a server-only view can be blind to the majority of the reads, which are often browser-side.

Will measuring my Firestore reads add to my read bill?

It should not, if the meter is built correctly. A correct read meter counts the documents a query already returned and keeps the tally in memory — it runs no extra query, no EXPLAIN, and no profiler scan. A cost tool that itself costs reads is worse than none, so this is the one property to insist on.

How do I prove a Firestore read-cost fix actually worked?

Compare reads per hour on the same feature immediately before and after you ship the fix, not day totals. Hourly grain lets you watch a change land the same hour instead of guessing from next month's invoice, and per-feature attribution confirms the right bucket moved down rather than the whole bill drifting for unrelated reasons.

Does any of this apply to MongoDB, Postgres, or Supabase?

Yes. The billing units differ — Firestore charges per document read, MongoDB by documents returned, Postgres and Supabase by compute that scales with rows scanned — but the diagnosis is identical: attribute the read load to the feature and the user that caused it, then cut the biggest bucket first. The same collector model covers Firestore, MongoDB, and Postgres.

Crossdeck Editorial Team

Crossdeck publishes practical guides about subscription infrastructure, entitlements, revenue analytics, and error reporting for paid apps. Every guide is reviewed against Crossdeck docs, SDK behaviour, and implementation details before publication.

Put names on your Firestore reads

Install the open-source Buckets collector to see where your Firestore reads go — attributed to the function and the user that caused them, and never costing you a read to run. It is MIT-licensed and free. Add Crossdeck to stitch server and browser reads into one view and get paged before the invoice.