The database architecture behind students, meetings, attendance, authentication, and controlling unnecessary Firestore reads.*
How I Structured My Firestore Data Model for an Anti-Proxy Attendance System
When I began building my attendance system, database architecture wasn't really my first concern.
I had one straightforward objective:
Make the MVP work.
I needed a place to store students.
I needed a way to create meetings.
I needed to save attendance records.
So I started creating Firestore data structures based on whatever the application needed at that moment.
Initially, that approach worked.
Then the application started expanding.
Authentication was introduced.
Dynamic QR authentication was introduced.
Device verification was introduced.
Attendance history was introduced.
Offline synchronization was introduced.
And suddenly, Firestore was doing much more than simply holding attendance records.
It was turning into the foundation that the rest of the application depended on.
That's when an important realization hit me:
A schema that is perfectly fine for an MVP may not remain suitable once the application starts growing.
The First Question: What Really Needs to Live in the Database?
An attendance system sounds straightforward until you actually list everything it needs to keep track of.
For every attendance event, I may need details such as:
- The student
- The meeting
- The attendance status
- The time the attendance was recorded
- Authentication information
- Device information
- Synchronization state
- Administrative information
The difficult part wasn't simply finding a place to store all of these things.
The harder problem was determining how all of these pieces should connect with one another.
I started looking at the database through the entities that actually existed inside the system.
Student
│
├── Identity
├── Device
└── Attendance
│
▼
Meeting
│
├── QR Session
└── Attendance Records
The database needed to represent these relationships without making the application repeatedly retrieve the same information.
Firestore Forces a Different Way of Thinking
Firestore doesn't work like a traditional relational database.
There are no conventional SQL joins where I can simply write something like:
SELECT ...
FROM students
JOIN attendance ...
JOIN meetings ...
Instead, I need to think about a different question:
Which pieces of data does the application need to access together?
That question became one of the most important considerations while designing the schema.
In Firestore, the structure of your data directly influences how much information the application has to retrieve.
And I had already experienced the consequences of getting that wrong.
I Still Remember the 50,000 Read Problem
Earlier during development, I accidentally burned through my Firestore free-tier reads while testing and debugging the application.
That experience completely changed the way I looked at database operations.
Before that happened, my thinking was basically:
"It's just one additional read."
Afterwards, my question became:
"Does the application actually need this read?"
That shift in mindset started influencing the database architecture as the project continued to evolve.
Designing Around Actual Access Patterns
Rather than beginning with:
"Which collections should I create?"
I started approaching the problem from the opposite direction:
"What information does the application actually need to retrieve?"
For example, when an attendance session is running, the system needs enough information to validate the request and then record the attendance correctly.
So the database has to support operations such as:
Create Meeting
↓
Generate Attendance Session
↓
Validate Student
↓
Validate Device
↓
Validate QR
↓
Record Attendance
The database should not become the thing slowing down every stage of the process.
Keeping Attendance as a Separate Record
One of the key decisions I made was to treat attendance as its own record rather than repeatedly changing a student's profile whenever they attend a meeting.
Conceptually, the relationship looks like this:
Student
│
└──────────────┐
│
▼
Attendance
│
▼
Meeting
This allows attendance history to exist separately from the student's core profile.
Instead of only asking:
"Is this student present?"
the system can answer a much more useful question:
"Was this student present for this particular meeting, and when exactly was that attendance recorded?"
That distinction is important.
Attendance represents an event, rather than simply being a permanent property attached to a student.
More Data Does Not Automatically Mean a Better Database
A common trap during database design is duplicating information everywhere because it makes individual queries feel easier.
At first, that can seem convenient.
The problem appears later when those duplicated values need to stay consistent.
For example, copying the same information into several documents may simplify one specific query, but now the application has to maintain multiple versions of the same data.
So I constantly found myself balancing:
Fast reads
against
Data duplication
against
Write complexity
against
Firestore usage
There isn't one universal answer that solves all of these concerns.
The right decision depends on how the application actually reads and writes the data.
What an Attendance Request Actually Contains
At a high level, an attendance request now carries several different pieces of context.
Attendance Request
│
├── Student Identity
│
├── Meeting
│
├── QR Validation
│
├── Device Verification
│
└── Attendance Record
So attendance isn't really just:
student → present
It's closer to:
student
+
meeting
+
valid session
+
trusted device
+
validated request
=
attendance
That means the database is not merely a storage layer.
It becomes part of the security architecture as well.
Thinking Carefully About Reads and Writes
After experiencing the 50,000-read issue, I started paying much more attention to the difference between reads and writes.
A system can function correctly and still perform far more database operations than it actually needs.
For example:
Page Load
↓
Read Student
↓
Read Meeting
↓
Read Configuration
↓
Read Student Again
↓
Read Meeting Again
Nothing is technically failing here.
But the application is doing unnecessary work.
So I started looking for ways to:
- Reuse information that the application already has
- Eliminate repeated reads
- Cache frequently accessed information
- Minimize unnecessary writes
- Fetch only the data that is actually required
This eventually became part of the larger optimization work I covered in Part 3.
Firestore Security Goes Beyond Security Rules
Another lesson from the project was that database security cannot be reduced to Firestore Security Rules alone.
Rules are important.
But they represent only one layer of the overall security model.
In my attendance system, validation happens across several stages:
User Authentication
↓
Dynamic QR Validation
↓
Device Verification
↓
Backend Validation
↓
Firestore
↓
Attendance Record
The database should never blindly trust whatever information the frontend submits.
The backend needs to validate the request before writing sensitive attendance information.
This separation became even more important as more anti-proxy mechanisms were introduced into the system.
Offline Support Changes the Data Lifecycle
Then another challenge appeared.
Offline support.
Once the system could record attendance without immediately communicating with Firestore, the data model needed to distinguish between two different states:
Attendance created locally
and
Attendance successfully synchronized
Conceptually, the lifecycle became:
Student
↓
Attendance Event
↓
Local Storage
↓
Pending Sync
↓
Firestore
↓
Synchronized
That meant the lifecycle of an attendance record was no longer simply:
Create → Save
It became:
Create
↓
Store locally
↓
Wait
↓
Synchronize
↓
Confirm
That changed the way I thought about reliability as well.
What I Would Avoid Doing
One of the clearest lessons from this project is that Firestore schemas should not be designed by blindly copying relational database patterns.
Firestore has its own strengths, limitations, and way of handling data.
Instead of asking:
"How would I structure this in MySQL?"
I found it much more useful to ask:
"What are the operations my application performs most often?"
Then I could design the data model around those access patterns.
That single shift in perspective made the database design much easier to reason about.
The Bigger Lesson From the Data Model
At the beginning, the database felt like infrastructure.
Something beneath the actual application.
But as the system became more complex, I realized that the data model affects almost every major part of the application:
- Performance
- Cost
- Security
- Offline synchronization
- Backend architecture
- Query complexity
- Scalability
A poor data model doesn't necessarily cause an obvious failure on day one.
Sometimes the problem is much quieter.
It simply makes every new feature a little harder to build.
And that can become a much bigger problem over time.
What I Would Change Today
If I were starting this system again today, I would spend considerably more time thinking about the data model before implementing features.
I'd define:
Entities
↓
Relationships
↓
Access Patterns
↓
Read / Write Frequency
↓
Caching Strategy
↓
Security Boundaries
before writing the first database query.
I learned this lesson after the system was already functioning.
Building first showed me what the application genuinely needed.
But if I rebuilt it from scratch, I'd bring those lessons into the design phase much earlier.
Final Thoughts
The original requirement sounded simple:
"I just need somewhere to store attendance."
Over time, that requirement evolved into something much larger:
"I need a data architecture that can support authentication, dynamic QR validation, device verification, offline synchronization, and efficient reads and writes."
Those are two completely different problems.
And probably one of the biggest lessons this project gave me is that database design isn't simply about deciding where data should be stored.
It's about defining how the entire application communicates with that data.
The database isn't simply sitting underneath the architecture.
It is part of the architecture.
What's Next?
The database was only one part of the overall system.
The next challenge was infrastructure.
My backend was running on a free-tier service, and that introduced another constraint that became difficult to ignore:
cold starts.
Which raised another question:
How do you make a slow backend feel fast from the user's perspective?
That's the problem I'll explore next.
🔗 Project
GitHub:
https://github.com/siddarthpatelkama/UBA-veltech-attendance-system
💬 Your turn
If you've built a Firestore application before, what's one database decision you made early that later saved you from dealing with a much bigger problem?
I'd love to hear about it.