Firestore read scaling is close to free of thought: add an index, keep queries narrow, watch the bill. Write scaling is not. It has hard shapes, and when you cross them the database does not slow down gracefully — it returns errors.
The error text is usually one of these:
ABORTED: Too much contention on these documents. Please try again.
DEADLINE_EXCEEDED
RESOURCE_EXHAUSTED
Every one of those is a write-path capacity problem wearing a different hat. There are three limits behind almost all of them. Learn the three and you can predict the failure before the launch instead of debugging it during.
Limit 1: roughly one sustained write per second, per document
A single Firestore document supports about one sustained write per second. Bursts above that are fine for a few seconds; sustained load above it produces latency first and contention errors second.
This limit is per document, not per collection, and it catches teams on exactly one pattern: the aggregate. A view counter, a like counter, an org-wide stats document, a "last activity" field on a shared parent record.
// Fine at 40 users. Broken at 4,000.
await updateDoc(doc(db, 'posts', postId), {
views: increment(1),
});
increment() is a server-side atomic operation, which removes the read-modify-write race but does nothing about the write rate. A post on the front page of your app taking 30 views a second is 30x over the document's budget. The writes queue, latency climbs into seconds, and clients start timing out.
Fix A: shard the counter
Split the counter across N documents and sum on read. Ten shards buys you roughly ten writes per second.
const SHARDS = 10;
async function incrementViews(postId) {
const shard = Math.floor(Math.random() * SHARDS);
await setDoc(
doc(db, 'posts', postId, 'shards', String(shard)),
{ views: increment(1) },
{ merge: true },
);
}
async function readViews(postId) {
const snap = await getDocs(collection(db, 'posts', postId, 'shards'));
return snap.docs.reduce((n, d) => n + (d.get('views') ?? 0), 0);
}
The tradeoff is on the read side, and it is arithmetic you should do before you pick N. Ten shards means every read of the count is ten document reads instead of one. An app that displays view counts on a 20-item feed goes from 20 reads per screen to 220. At 40,000 sessions a day and five screens per session that is 44,000,000 reads a month instead of 4,000,000 — about $24 a month of extra read charges at $0.06 per 100,000 reads, to solve a write problem.
So shard the counters that are genuinely hot, at the smallest N that clears the write rate, and do not shard the rest. We generally cache the summed value back onto the parent document on a schedule, so the hot read path reads one document and the exact number is only assembled when somebody needs it exact.
Fix B: do not keep a counter at all
Aggregation queries — count(), sum(), avg() — let you ask the index instead of maintaining a number.
import { getCountFromServer, query, collection, where } from 'firebase/firestore';
const snap = await getCountFromServer(
query(collection(db, 'orders'), where('status', '==', 'open')),
);
const openOrders = snap.data().count;
Billing for an aggregation is one document read per up-to-1000 index entries scanned, minimum one. Counting 5,000 matching documents costs five reads, not 5,000, and zero writes. For anything a human reads occasionally — dashboards, admin screens, "N items match your filter" — this is strictly better than a maintained counter: no write contention, no drift, no reconciliation job.
Where it does not fit: values rendered on every request at high volume, where the per-call read cost beats one cached field, and counts above a few tens of thousands where the scan cost grows linearly. Aggregations also have a 60-second timeout and no offline support. Use them for the long tail of counts; keep a cached field for the two numbers on your home screen.
Fix C: buffer the write
If the number does not need to be live, stop writing per event. Append events to a collection — one document per event, no contention, since each write hits a different document — and fold them into the aggregate on a schedule.
import { onSchedule } from 'firebase-functions/v2/scheduler';
export const rollupViews = onSchedule('every 5 minutes', async () => {
const events = await db.collection('viewEvents').where('folded', '==', false).limit(5000).get();
const perPost = new Map<string, number>();
events.docs.forEach((d) => perPost.set(d.get('postId'), (perPost.get(d.get('postId')) ?? 0) + 1));
const batch = db.batch();
for (const [postId, n] of perPost) {
batch.set(db.doc(`posts/${postId}`), { views: FieldValue.increment(n) }, { merge: true });
}
events.docs.forEach((d) => batch.update(d.ref, { folded: true }));
await batch.commit();
});
One write per post per five minutes, regardless of traffic. The number is up to five minutes stale. For view counts, nobody has ever noticed.
Limit 2: about 500 writes per second, per index range
This is the limit teams do not know exists, and it is the one that makes a bulk import or a launch fall over.
Firestore indexes are sorted. Writes are distributed across the key space. If your indexed values are monotonically increasing — a timestamp, a sequential ID, an auto-increment number — every new write lands at the same end of the index, on the same storage range. That range has a ceiling of roughly 500 writes per second, and you hit it no matter how many documents you are writing across.
The usual culprits:
- Sequential document IDs.
order-000001,order-000002. Firestore's auto-generated IDs are deliberately random for exactly this reason; hand-rolled sequential IDs throw that away. - An index on
createdAt. Unavoidable in principle — you want to sort by time — but a single-field ascending index on a write-heavy collection concentrates every insert at the head of the range. - A low-cardinality field indexed alongside a timestamp, e.g.
where('status','==','pending').orderBy('createdAt')on a queue where 99% of new rows arepending.
What to do about it
For IDs: use doc(collection(db,'orders')).id or any random ID. If you need a human-readable order number, store it as a field and exempt it from indexing, or prefix it with a hash so the index distributes.
For timestamps, add a random shard field to the index and query across shards:
await addDoc(collection(db, 'events'), {
createdAt: serverTimestamp(),
shard: Math.floor(Math.random() * 10), // 0-9
// ...
});
Now index (shard, createdAt). Writes spread across ten ranges — 5,000 writes per second of headroom. Reads fan out to ten queries and merge client-side, which costs you code and a merge sort but keeps the collection writable.
For fields you never query on, turn the index off. Single-field index exemptions are free to add and remove a write cost you were paying for nothing:
{
"indexes": [],
"fieldOverrides": [
{
"collectionGroup": "events",
"fieldPath": "payload",
"indexes": []
},
{
"collectionGroup": "events",
"fieldPath": "orderNumber",
"indexes": [{ "order": "ASCENDING", "queryScope": "COLLECTION" }]
}
]
}
Every index entry is work on the write path. A document with 30 indexed fields, several of them large maps, is a slow document to write. Exempting fields you never filter or sort on is the cheapest write-latency fix available, and it reduces storage too. Note that exemptions apply going forward and to a re-index; check the index page after deploying.
And when you ramp traffic into a new collection, respect the 500/50/5 guidance: start around 500 writes per second and increase by 50% every five minutes. Firestore splits ranges automatically as load grows, but the splitting takes minutes. A cold collection hit with 20,000 writes per second on launch morning will fail; the same collection ramped over an hour will not.
Limit 3: transactions retry, and retries have a budget
A Firestore transaction is optimistic. It reads, you compute, it commits — and if any document it read changed in the meantime, the commit fails and the whole transaction runs again. The SDKs retry for you. The retries are not free and they are not infinite.
This interacts badly with two habits:
Reading more than you need. A transaction that reads a broad query holds a lock over a wider footprint than a transaction that reads two documents by ID. Wider footprint, higher chance something moved, more retries. Read by ID inside transactions where you can.
Using a transaction where increment() would do. This is the common one:
// Retries under load. Don't.
await runTransaction(db, async (tx) => {
const snap = await tx.get(ref);
tx.update(ref, { likes: (snap.get('likes') ?? 0) + 1 });
});
// One round trip, no read, no retry.
await updateDoc(ref, { likes: increment(1) });
The transaction version does a read, a compute and a conditional commit; the increment() version is a single commutative server-side operation. Both are still bound by one write per second per document, but the transaction burns a read and a retry cycle on top of it. Use transactions when you need to make a decision based on current state — inventory that must not go negative, a uniqueness claim, a limit check — and atomic field operations when you only need to change a number.
When you do need the decision, make it cheap:
await db.runTransaction(async (tx) => {
const snap = await tx.get(inventoryRef); // single document, by ID
const onHand = snap.get('onHand') ?? 0;
if (onHand < qty) throw new HttpsError('failed-precondition', 'Out of stock');
tx.update(inventoryRef, { onHand: onHand - qty });
tx.set(db.collection('reservations').doc(), { sku, qty, uid }); // random ID
});
One document read, two writes, a random ID for the new document so the insert does not hotspot. If a single SKU genuinely takes hundreds of purchases a second, that inventory document is a hot document and needs the shard treatment from Limit 1 — or a queue in front of it, which is usually the more honest design.
How to tell which limit you are hitting
Before you refactor, confirm the shape. Three places to look, in order:
- Cloud Monitoring,
firestore.googleapis.com/api/request_count, grouped byresponse_code.ABORTEDclimbing means contention — Limit 1 or Limit 3.DEADLINE_EXCEEDEDon writes with normal read latency means the write path is queueing. - The Key Visualizer in the Cloud console. It draws your key space against traffic. A hot index range shows up as a bright horizontal band that never moves. This is the fastest way to confirm Limit 2, and it is the tool most teams have never opened.
- Cloud Trace on the offending function. If commit latency is the whole span, it is contention, not your code.
If the contention is on one named document, shard or buffer it. If it is spread across a collection but concentrated in one index, look at what you are indexing and whether it increases monotonically. If it only appears inside transactions, look at how much each transaction reads.
The short version
- One sustained write per second per document. Counters on hot entities need shards, an aggregation query, or a rollup job.
- About 500 writes per second per index range. Random document IDs, shard fields next to timestamps, index exemptions for fields you never query, and a 500/50/5 ramp into new collections.
- Transactions retry on conflict. Use
increment()for arithmetic, transactions for decisions, and read by ID inside them.
None of this is a reason to leave Firestore. These limits sit far above what most applications ever need, and the fixes are local — a field, an index exemption, a subcollection. The expensive version is discovering them during a launch. Write-path capacity is part of what we check in a Firebase audit, and the fixes above are the sort of thing we implement under Cost & Performance Engineering. If the hot path turns out to need a different database entirely, that call is covered in When to Move a Hot Path from Firebase to Cloud Run.
Trademark note: Firebase, Firestore, and Google Cloud are trademarks of Google LLC. FlameRangers is an independent consultancy and is not affiliated with or endorsed by Google.