When I started building Popper, I chose Firebase for speed and simplicity. Now with 1,100+ users and 60+ businesses processing thousands of transactions weekly, our infrastructure has evolved significantly. Here's how we scaled our Firebase architecture to handle our growth.
Our MVP was straightforward: Authentication, Firestore for data, and Cloud Functions for business logic. This served us well for the first few hundred users, but as we grew, we encountered our first scaling challenges.
The most pressing issue was read/write throughput during peak hours. When multiple businesses processed transactions simultaneously, we started hitting Firestore's per-document write limits, causing failed transactions and frustrated users.
Our first major optimization was rethinking our data model. We initially structured our database in a relational way, which led to inefficient queries as we scaled. The key insight was embracing Firestore's document-oriented nature rather than fighting against it.
We implemented three critical changes:
Instead of joining data across collections, we strategically denormalized data to optimize for our most common read patterns. For example, we embedded recent transaction summaries directly in user documents:
// Before: Separate collections requiring joins
users/{userId}
transactions/{transactionId} // with userId field
// After: Embedded recent transactions
users/{userId} {
// User data
recentTransactions: [
{ businessId, amount, points, timestamp }
] // Limited to last 5
}
This reduced our most common query from multiple reads to just one, significantly improving app responsiveness.
For high-volume writes like transactions, we implemented a sharding strategy to distribute writes across multiple documents, avoiding Firestore's per-document write limits:
// Generate a shard ID based on timestamp
const shardId = Math.floor(Date.now() / 1000 / 60) % NUM_SHARDS;
const shardRef = db.collection('transactionShards').doc(`shard_${shardId}`);
// Add transaction to the appropriate shard
await shardRef.collection('transactions').add({
userId,
businessId,
amount,
timestamp: firebase.firestore.FieldValue.serverTimestamp()
});
This approach distributed our write load across multiple shards, effectively multiplying our write throughput.
Many of our users interact with Popper in locations with spotty connectivity. We leveraged Firestore's offline capabilities but had to carefully manage the cache size and conflict resolution:
// Enable offline persistence with custom settings
firebase.firestore().enablePersistence({
synchronizeTabs: true,
cacheSizeBytes: 5000000 // 5MB cache limit
}).catch(err => {
if (err.code === 'failed-precondition') {
// Multiple tabs open, persistence can only be enabled in one tab
console.log('Offline persistence unavailable: Multiple tabs open');
} else if (err.code === 'unimplemented') {
// Client doesn't support persistence
console.log('Offline persistence not supported by browser');
}
});
Our Cloud Functions architecture evolved from simple triggers to a more sophisticated event-driven system. The key improvements included:
For operations affecting multiple documents, we implemented batched writes to ensure atomicity:
exports.processTransaction = functions.https.onCall(async (data, context) => {
const { userId, businessId, amount } = data;
const db = admin.firestore();
const batch = db.batch();
// Calculate points based on business rules
const points = calculatePoints(amount, businessId);
// Update user points
const userRef = db.collection('users').doc(userId);
batch.update(userRef, {
points: admin.firestore.FieldValue.increment(points)
});
// Create transaction record
const transactionRef = db.collection('transactions').doc();
batch.set(transactionRef, {
userId,
businessId,
amount,
points,
timestamp: admin.firestore.FieldValue.serverTimestamp()
});
// Update business stats
const businessRef = db.collection('businesses').doc(businessId);
batch.update(businessRef, {
totalTransactions: admin.firestore.FieldValue.increment(1),
totalRevenue: admin.firestore.FieldValue.increment(amount)
});
// Commit all changes atomically
await batch.commit();
return { success: true, points };
});
To reduce the cost of analytical queries, we implemented scheduled functions that pre-compute aggregations:
// Run daily at midnight
exports.calculateDailyStats = functions.pubsub
.schedule('0 0 * * *')
.timeZone('America/Los_Angeles')
.onRun(async context => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
// Query transactions from yesterday
const snapshot = await admin.firestore()
.collection('transactions')
.where('timestamp', '>=', yesterday)
.get();
// Aggregate by business
const businessStats = {};
snapshot.forEach(doc => {
const data = doc.data();
if (!businessStats[data.businessId]) {
businessStats[data.businessId] = {
transactions: 0,
revenue: 0
};
}
businessStats[data.businessId].transactions++;
businessStats[data.businessId].revenue += data.amount;
});
// Store aggregated results
const batch = admin.firestore().batch();
for (const [businessId, stats] of Object.entries(businessStats)) {
const ref = admin.firestore()
.collection('businessStats')
.doc(businessId)
.collection('daily')
.doc(yesterday.toISOString().split('T')[0]);
batch.set(ref, stats);
}
return batch.commit();
});
Scaling our Firebase infrastructure taught us several valuable lessons:
As we continue to grow, we're exploring additional optimizations and architectural improvements. The key is maintaining the balance between performance, cost, and development velocity that made Firebase attractive in the first place.
I'd love to hear about your experiences scaling Firebase or other NoSQL databases. Connect with me on Twitter to continue the conversation.