Skip to main content
← Back to blog

Case Study: Cutting Firestore Reads 31% in a Real Next.js Education App

9 min readReadMeter Team

Most Firestore cost advice is hypothetical: "imagine 10,000 users and an N+1 query." This post is the opposite. We pointed ReadMeter at a real Next.js + Firebase app with real class data, measured every page, fixed what it found, and measured again with the same tool.

The app is The Globals, an education platform with teacher and student roles. In the interest of full disclosure, The Globals is built by the same person who makes ReadMeter, so treat this as a worked example, not a customer testimonial. The data came from one school we'll call Larkfield Academy. That name is fictional: the real school isn't named, for privacy, and no student or teacher data appears in this post.

Results at a glance

What we measuredBeforeAfterChange
Teacher: one pass through all 8 pages3,882 reads2,664 reads−31%
Student: one pass through all 11 pages1,588 reads1,192 reads−25%
Goals completion report (follow-up fix)1,019 reads693 reads−32%, measured on in-app navigation

Every page showed the same content before and after. We compared the visible text of all 19 pages to make sure no fix quietly dropped data.

Note: These are the production-like numbers, measured with React strict mode turned off. With strict mode on (the Next.js dev default), the same comparison looked like −43% for teachers and −52% for students. That bigger number is inflated: strict mode runs effects twice in development, which doubled many "before" reads, and some fixes mostly removed those dev-only duplicates. We quote the smaller figures.

How we measured

  • Tool: ReadMeter 1.2.1, in the app's dev server.
  • Pages: every page in the teacher menu (8) and the student menu (10, plus a lesson page).
  • Loads: one warm-up load per page, then repeated full reloads, reading the counter 12 seconds after each one. Almost every page read exactly the same amount on every load, so before and after are directly comparable.
  • Same accounts, same pages, same steps for "before" and "after."

About 99.8% of the teacher reads happened in the browser, so this was a client-side story.

Every fix started from a read source

ReadMeter ranks the code paths behind each page's reads: the file and line, the collection, how many documents came back, and how many times the call ran during one load. That last column did most of the diagnosis on its own:

  • Hundreds of runs on one line meant a read inside a loop.
  • 2 runs meant two components fetching the same thing, or strict mode doubling it.
  • The right file on the wrong page meant a bug, as the lesson page below shows.

After each fix we ran the same sweep and checked that the read source shrank or disappeared exactly where we expected. No guessing, and no waiting for next month's bill.

The biggest win: one class, queried three times

On the teacher's "My students" page and the goals report, the top read source was the class's submissions. The data model had changed over time, so a submission could identify its class in one of three fields: a current class key, a legacy class name, or a grade. The code ran three queries, one per field, and merged the results.

They returned 275, 268 and 275 documents. That's 818 reads per page load, for what was really one set of about 275 submissions. Most documents matched all three fields, so the app paid for each one up to three times.

The fix was one or() query, split back into its three groups in code:

// Before: three queries, overlapping results merged in code
const [byKey, byLegacyName, byGrade] = await Promise.all([
  getDocs(query(submissions, where("classId", "==", classId))),
  getDocs(query(submissions, where("legacyClassName", "==", legacyName))),
  getDocs(query(submissions, where("grade", "==", grade))),
]);

// After: one query; each matching document is returned (and billed) once
const snap = await getDocs(
  query(
    submissions,
    or(
      where("classId", "==", classId),
      where("legacyClassName", "==", legacyName),
      where("grade", "==", grade)
    )
  )
);

Same indexes, same rows (covered by unit tests). This one change saved 1,084 reads per teacher pass and 205 per student pass. With a smaller fix on top, "My students" went from 1,129 reads to 553 (−51%).

The student page that loaded the teacher catalog

The heaviest student page was a single lesson. More than half of its reads came from a file named like a teacher feature, reading the goal-catalog collection, 38 times per load. On a student page.

The lesson page called the teacher goal hook unconditionally. It loaded the whole goal catalog, with every goal's sections, and only used the result if the viewer was a teacher. For students, all of it went in the bin.

Making the call teacher-only, together with the submissions fix above, took the lesson page from 312 reads to 114 (−63%). Nobody would have spotted this from the Firebase console's totals. It took seeing which file was reading which collection on which page.

One read per class per day

The teacher's account page draws an activity chart. To build it, the code ran one getDoc per class per day: 119 reads per load. Most days had no activity document, but a getDoc for a missing document still costs a read.

Replacing the loop with one document-ID query per class brought that to 28 reads. Days with no document no longer cost anything. The account page went from 369 reads to 278 (−25%).

Fewer queries ≠ fewer reads

The audit also turned up a classic N+1: one query per goal to fetch its sections, 139 reads on several teacher pages. It looks like the obvious next fix.

We left it alone. Firestore bills per document returned, not per query. Merging those queries into one would return the same documents, so the bill would barely move. The only saving would be the one-read minimum Firestore charges for each query that returns nothing.

The run count makes N+1 patterns easy to spot, but not every N+1 is worth fixing. Ask how many documents come back, not how many calls go out. (More on this in how Firestore read costs work and N+1 queries and the visibility gap.)

Two smaller fixes let components share in-flight requests instead of fetching the same data twice. They're correct and cheap, but in development they mostly removed strict-mode duplicates; their production saving was small (about 60 reads per teacher pass), so we don't sell them as big wins.

Follow-up: the goals completion report

A day later we went after one more source: the report's per-student work rows. For each student, the code listed every submission they had ever made, then filtered by name in code to find the handful the report needed.

The class read earlier on the same page had already found those exact documents and thrown away their IDs. Keeping the IDs and reading just those documents took the report from 1,019 reads to 693 (−32%), with a character-for-character identical table.

Note: This number comes from a different setup than the tables above. It was measured after navigating to the report inside the app, not on a full reload, so it leaves out the roughly 30 reads the app shell costs on a full load. It was also measured in dev with strict mode on. "Before" and "after" were measured the same day, the same way, on the same data, so the −32% holds; just don't add it to the full-reload totals.

What it's worth in dollars

The numbers above are for one teacher or one student going through the app once. The Globals had 6,600 daily active users on the day we finished (33K over the last 7 days and 66K over the last 30, from Firebase Analytics), so here is the same result at real traffic.

We assumed each daily user makes one pass through the app per day, and priced every user as a student, which is the smaller saving (396 reads per pass, against 1,218 for a teacher). That keeps the estimate conservative.

At 6,600 daily usersBeforeAfterChange
Reads per month314.4M236.0M−78.4M (−25%)
Cost per month at $0.06 per 100k reads$188.65$141.61−$47.04

That's about $565 a year, from five fixes. The free tier (50,000 reads a day) doesn't change the saving, because the app reads well past it either way. Firestore prices vary by region, and real users don't all make exactly one pass a day, so treat this as an order of magnitude, not an invoice. Every teacher among those users saves three times as much per pass as a student.

The invoice is only part of it. What else changed:

  • Latency. The heaviest pages fetch hundreds fewer documents before they render.
  • Quota headroom. Free-tier and budget limits last longer.
  • The right scaling curve. Reads now grow with the number of classes and students, not with how often someone hits reload.

The patterns we removed (fetching everything three times, loading data for the wrong role, a read per empty day) are the ones that turn into real money as a school becomes fifty schools.

What ReadMeter itself got wrong

Running a full audit on a real app is also the best test of the tool, and it found gaps:

  • No strict-mode awareness. ReadMeter reported dev counts without flagging that about a third of the teacher reads were strict-mode doubles. Telling real savings from dev-only ones took a separate run.
  • No saved baselines. The before/after comparison here was a manual sweep plus JSON files. A named baseline you can compare against later should be built in.
  • Runs, not just reads. Showing run counts makes every N+1 look worth fixing. ReadMeter should say when merging queries can save at most a handful of reads.

An earlier pilot run on the same app also caught real counting bugs, such as server reads only being counted on the first request and empty queries counting as zero. Those were fixed in 1.2.1, before the numbers in this post were measured.

Takeaway

Three real data-access fixes and two small clean-ups cut reads by a quarter to a third on every pass through the app. Each one started from a single line in ReadMeter's read sources and was confirmed by the same tool. If you want to know which file in your app is reading which collection, and how many times per page load, that's what ReadMeter is for.

See your Firestore reads while you build

Get visibility into read patterns before traffic arrives. $9 once, on up to 3 of your machines.

Get ReadMeter — $9 (one-time)