DEV Community

Cover image for What Exhausting 50,000 Firestore Reads Taught Me About Database Design
siddarthpatelkama
siddarthpatelkama

Posted on

What Exhausting 50,000 Firestore Reads Taught Me About Database Design

How I redesigned the data layer of my anti-proxy attendance system around access patterns, security, offline synchronization, and efficient Firestore usage.

When I first built my attendance system, I wasn't thinking much about database architecture.

I had a working MVP.

Students could log in.
Teachers could create attendance sessions.
Students could scan dynamic QR codes.
Attendance records were stored in Firestore.

It worked.

Until I started using it heavily.

Then I learned a lesson that only becomes obvious after something breaks:

Your database structure isn't just about how you store data. It's about how your application needs to retrieve it.

And in my case, that lesson started with 50,000 Firestore reads.

The Problem Wasn't Just the Quota

In an earlier part of this series, I wrote about how I unexpectedly exhausted my Firestore free-tier reads while developing and testing the system.

That incident pushed me to build offline capabilities and start reducing unnecessary database operations.

But it also exposed a deeper problem.

I had been thinking about Firestore mainly as:

"Where should I store this data?"

I needed to start thinking:

"What data will my application need to retrieve, how often, and in what combination?"

That shift changed how I looked at the entire data layer.

  1. I Stopped Thinking in Tables

Coming from SQL-style database thinking, my first instinct was to model everything around entities.

Students.

coordinaters.

Attendance.

meetings.

Sessions.

That isn't necessarily wrong.

But Firestore isn't a relational database with tables and joins.

It is a document-oriented database built around collections and documents, with support for nested structures and subcollections.

So instead of asking:

"What are all my entities?"

I started asking:

"What does the application actually need to read?"

That sounds like a small difference.

It wasn't.

  1. I Started With Access Patterns

Consider what happens when a student tries to mark attendance.

The system needs to establish several things:

Who is this user?
Is the authentication valid?
Is the dynamic QR valid?
Is this device associated with the account?
Is there an active attendance session?
Has this student already marked attendance?
Should an attendance record be created?

The important thing here is that these aren't independent database operations.

They're part of one application workflow.

That meant the database structure had to support the workflow rather than forcing the application to repeatedly search for unrelated pieces of information.

  1. Attendance Isn't Just a Property of a Student

One of the most important modeling decisions was treating attendance as its own record.

A student isn't simply:

Student
attendance = 87%

That number doesn't tell me much.

I need to know:

Which meeting?
Which session?
Which date?
Was the attendance marked successfully?
What authentication flow was used?
Was the request associated with the expected device?
When was it recorded?

Attendance is really an event.

And events deserve their own records.

This also gives the application something much more useful to work with.

Instead of asking:

"What is this student's attendance?"

the system can ask:

"What attendance events belong to this student?"

That distinction becomes increasingly important as the application grows.

  1. The Database Became Part of the Security Pipeline

This was another thing I didn't appreciate when I started.

My dynamic QR system already had multiple layers of verification.

But authentication doesn't end when the QR is scanned.

The request still needs to move through the backend before attendance is accepted.

The general flow became:

Student

User Authentication

Dynamic QR Validation

Device Verification

Backend Validation

Firestore

Attendance Record

The database isn't responsible for deciding whether a student is trustworthy.

But it stores the state that the backend needs to make that decision.

That makes the data model part of the security architecture.

And this is one reason I became much more careful about where authentication-related state lived.

  1. I Learned Not to Read Everything

This sounds obvious.

But it's surprisingly easy to accidentally build an application that retrieves far more data than it needs.

For example, imagine a dashboard that only needs the current attendance session.

There is no reason for the application to retrieve an entire history of attendance records just to determine what is happening right now.

The same principle applies everywhere:

Don't ask:
"How much data can I retrieve?"

Ask:
"What's the smallest amount of data this operation needs?"

Firestore supports document-level queries, filtering, sorting, limits, and pagination mechanisms, which makes this style of access-pattern-driven design possible.

This became particularly important after my 50,000-read incident.

Every unnecessary read stopped feeling harmless.

  1. Offline Support Changed the Data Flow

Then there was another complication.

The attendance system couldn't assume that the network would always be available.

That meant the architecture eventually needed to support a flow like:

User Action

Local State

Pending Attendance

Network Available?
↙ ↘
YES NO
↓ ↓
Firestore Keep Locally

Sync Later

Now the database wasn't simply receiving requests from the frontend.

There was a synchronization problem too.

An attendance action could exist locally before it existed remotely.

That forced me to think about things like:

pending records
duplicate submissions
synchronization
conflict handling
what happens when the network comes back
how the backend should validate synced data

The database architecture therefore had to coexist with the offline architecture.

  1. Security and Performance Pulled in Different Directions

This was probably one of the more interesting tradeoffs.

Security wants more validation.

Performance wants fewer operations.

Offline support wants local state.

Consistency wants reliable synchronization.

And Firestore usage needs to remain efficient.

You can't optimize one dimension without considering the others.

For example:

More validation

More reads / writes

Potentially higher latency

Less validation

Fewer operations

Potentially weaker verification

So the goal wasn't:

"Use the fewest Firestore operations possible."

The goal was:

"Use the database operations that actually contribute to the correctness and security of the workflow."

That's a much better optimization target.

  1. The 50,000 Reads Incident Changed How I Debug

Before the quota incident, I mostly looked at whether the feature worked.

After it, I started asking different questions.

When something happened in the UI:

What triggered this read?

Why was this document needed?

Could this have been cached?

Did the application already have this information?

Could I retrieve a smaller result?

Is this happening once or repeatedly?

That mindset is useful beyond Firestore.

A slow application isn't always slow because the database itself is slow.

Sometimes the application is simply asking the database the same question over and over.

  1. I Started Treating the Data Layer as Architecture

This became the biggest lesson from the whole process.

Initially, my architecture looked conceptually like:

Frontend

Backend

Firestore

But the real system became closer to:

                ┌──────────────┐
                │ Authentication│
                └──────┬───────┘
                       ↓
Enter fullscreen mode Exit fullscreen mode

┌────────────┐ ┌───────────────┐
│ Client │ ───→ │ Backend │
└─────┬──────┘ └───────┬───────┘
│ │
│ ↓
│ ┌─────────────┐
│ │ Firestore │
│ └──────┬──────┘
│ │
↓ ↓
Local State ←────── Synchronization

Firestore was no longer just "the place where I save attendance."

It became one component in a larger system involving:

authentication
authorization
attendance sessions
device state
offline synchronization
backend validation
caching
read/write optimization

That changed how I designed everything around it.

What I'd Do Differently If I Started Again

If I rebuilt the system from scratch, I would design the data layer before building most of the UI.

Not because I want a perfect schema from day one.

Because I'd want to understand the application's major access patterns first.

I'd write down questions like:

What does a student need to read?

What does a teacher need to read?

What must the backend validate?

What data changes frequently?

What data can be cached?

What must work offline?

What needs to be queried by date?

What needs to be queried by student?

What needs to be queried by attendance session?

Then I'd design the documents and queries around those questions.

That would have saved me a lot of unnecessary iteration.

The Bigger Lesson

I originally thought my biggest Firestore lesson would be:

"Don't exceed your free-tier quota."

It wasn't.

The bigger lesson was:

Database design starts with application behavior, not just data structure.

Once I understood how the application actually read and wrote data, several architectural decisions became easier.

The 50,000-read incident forced me to care about database usage.

Offline support forced me to care about synchronization.

Device authentication forced me to care about persistent identity state.

And together, they pushed me toward thinking about Firestore as part of the architecture rather than just a storage layer.

That's probably the biggest change in how I build systems now.

I don't start by asking:

"How should I store this?"

I start by asking:

"How will the system use this?"

And then I design the data around that answer.

What's Next?

So far, this series has gone from:

Dynamic QR authentication → Firestore quota problems → offline capability → device-bound authentication → database architecture

But there was another problem hiding in the system.

Even when the database and backend were working correctly, the application could still feel slow.

Especially when the backend had to wake up.

That led me to another engineering problem:

How do you make a backend with cold starts feel fast to the user?

That's what I'll cover in the next part.

Top comments (0)