Concurrency: two things at once
Two things at once
Concurrency means many things happening at the same time. If 5,000 people are playing Garden Defense at one moment, we have 5,000 concurrent users. Studios plan for the peak, the busiest minute, not the average.
Trouble starts when two players touch the same data at the same instant. The weekend shop has 1 Golden Acorn hat left. Two players tap Buy in the same second. Both games read "1 left", and both buy it. This is a race condition: the result depends on who wins a race of milliseconds.
The lost update
Garden Defense has a community goal: pop 1 million pests together. Remy's first version reads the total, adds 1 and writes it back. Now two phones pop a pest at the same moment:
- Phone A reads 500. Phone B reads 500.
- A writes 501. B writes 501.
Two pests popped, but the total grew by one. This is a lost update. One account on two devices has the same shape: an old tablet writes wave 9 a second after the phone wrote wave 12, and the last write wins.
Bug hunt
1const snap = await getDoc(goalRef);2const total = snap.data().pests;3await updateDoc(goalRef, {4 pests: total + 1,5});
Thousands of players pop pests at once, and the total grows slower than the real count. What is the problem?
- Between the read on line 1 and the write on line 3, another phone can write, and its pop is lost
- updateDoc can only store text, not numbers
- getDoc always returns 0 for shared documents
Show the answer
Between the read on line 1 and the write on line 3, another phone can write, and its pop is lost
Each phone writes back its own old total plus one, so pops that landed in between are overwritten. The fix is to let the server do the adding in one step.
Atomic increments
An operation is atomic when it can't be split: it happens completely or not at all, and nothing sneaks in halfway. Firestore can add to a number atomically on its own servers:
await updateDoc(goalRef, { pests: increment(1)});
The phone never reads the total, so there is no gap to lose updates in. Server code writes it FieldValue.increment(1). One limit: Firebase warns that updating one document too quickly causes contention or errors. For huge crowds, its guide splits a counter across several documents, a distributed counter.
Transactions: check, then act
The last hat needs a check before the change. That takes a transaction: a group of reads and writes that succeeds together or not at all.
Inside runTransaction, all reads come first, then the writes. If someone changes a document after we read it, Firestore throws our result away and runs the whole function again with fresh data, up to 5 tries by default in the web SDK. Transactions fail when the device is offline. And since the function may run several times, it must not play sounds or change the screen.
Read the code
1await runTransaction(db, async (tx) => {2 const shop = await tx.get(shopRef);3 const left = shop.data().goldenHats;4 if (left < 1) throw new Error("Sold out");5 tx.update(shopRef, { goldenHats: left - 1 });6 tx.update(playerRef, { sprigHat: "golden" });7});
Remy and another player run this at the same moment, and both read goldenHats: 1. What happens?
- One transaction commits. The other runs again, reads 0 and throws Sold out
- Both players get the golden hat, and the stock becomes -1
- Both transactions fail and nobody gets the hat
Show the answer
One transaction commits. The other runs again, reads 0 and throws Sold out
The first commit changes the shop document, so Firestore reruns the second transaction with fresh data. It now reads 0 and throws, so none of its writes happen. Lines 5 and 6 always succeed or fail together.
Idempotency: safe to do twice
Remy taps Buy on a bus, and the signal drops. Did it work? The game can't tell, so it tries again. If the server runs the purchase twice, Remy pays twice.
An action is idempotent when doing it twice has the same effect as doing it once. The trick: give each action a unique ID, and have the server remember which IDs are done. Firebase warns that one database event can wake a Cloud Function more than once, so functions must be idempotent. Stripe, our payment service, takes an idempotency key for the same reason.
Safe to retry?
1// A2setDoc(doc(db, "purchases", "p-7f3a"), order);3// B4addDoc(collection(db, "purchases"), order);5// C6updateDoc(ref, { seeds: increment(50) });
The game retries a write after the signal drops. Which one gives the same result whether it runs once or twice?
- A
- B
- C
Show the answer
A
A writes to a fixed ID, so a second run writes the same document and there is still one purchase. B picks a new random ID each time and makes a duplicate, and C adds 50 seeds again on every retry.
One thread, many waits
JavaScript runs one piece of code at a time, on a single thread, using the event loop: a queue of jobs that run in turn. At await, a function pauses and other jobs run until its answer arrives.
So a race can hide even inside one program, between an awaited read and an awaited write. On the server it's bigger: one Cloud Functions (2nd gen) instance can be set to serve up to 1,000 requests at once, and Google runs many instances side by side. Shared numbers belong in Firestore, not in a plain variable.
Calm in a crowd
Lots of players at once is every studio's dream, and it brings races. Our toolkit: atomic increments for counters, transactions for check-then-act, server-side checks so the server, not the phone, decides what is in stock, and idempotent actions that are safe to retry.
Next: the code that runs those server-side checks, Cloud Functions.
Bolt says: Whenever two players could touch the same data, ask the team: what if they both do it in the same millisecond?
Fact-checked 9 October 2026
- Firestore: transactions and batched writes firebase.google.com
- Firestore: TransactionOptions firebase.google.com
- Firestore: transaction data contention firebase.google.com
- Firestore: add and update data firebase.google.com
- Firestore: distributed counters firebase.google.com
- Cloud Functions: retrying events firebase.google.com
- Cloud Functions: manage functions firebase.google.com