"First to Find It Wins" Is Really a Race Condition Problem
A one-sentence product rule — first to find it wins — turns into a race condition problem on the backend. Here is how I guaranteed a single winner in Mysterious Treasure.
In Mysterious Treasure the product rule was a single sentence: whoever finds a treasure first wins it. Writing it in my notebook took five seconds. That its backend equivalent was a race condition problem is something I only realized while writing the first version.
The app was a location-based treasure hunt. Treasures were dropped onto the map, and the collect button unlocked once the user physically got close to that spot. There was one treasure and one winner. That is exactly where the trouble starts: saying "one" is easy, guaranteeing it is another job entirely.
Why the naive solution looks like it works
The first flow I wrote was this: read the treasure document, and if kazananId
is empty, write the winner. Testing on my own, it works perfectly. It stayed that way until
I tried tapping the same treasure from two phones at the same moment.
// Wrong: there is a gap between the read and the write
const snap = await hazineRef.get();
if (snap.data().kazananId == null) {
// Both devices can enter this line
await hazineRef.update({ kazananId: kullaniciId });
}
The bug here is not in the condition, it is in the time interval between the condition and the write. That interval is measured in milliseconds, but it is not zero. As long as it is not zero, two requests can enter it together. Bugs like this hide on quiet days and surface on busy ones.
The timeline of two devices
The clearest way to explain the problem is to interleave the steps of the two requests. Say A and B are two users who have both walked up to the same treasure:
| Step | Device A | Device B | Winner on the server |
|---|---|---|---|
| 1 | Reads the treasure: empty | — | empty |
| 2 | — | Reads the treasure: empty | empty |
| 3 | Opens the "you won" screen | — | empty |
| 4 | Writes: winner = A | — | A |
| 5 | — | Opens the "you won" screen | A |
| 6 | — | Writes: winner = B | B |
The result: both users saw that they had won, but the database says B. A has the reward on screen and nothing in their record. An inconsistency like this lands in the support inbox as "my reward disappeared" — and the user is right.
The client cannot make the decision
In the first version both the distance check and the "is this treasure still empty" check lived on the phone. Both are really just guesses. The only thing the phone can say is this: as far as I can tell, this treasure was empty a moment ago.
That is not a decision, it is a request. The only place that can make the decision is the single row everyone touches at the same time: the treasure document itself. The client's job is to send the request and wait for the answer.
I did not delete the checks on the phone entirely; they are still there to give the user instant feedback. But there is a difference in role between them now: the check on the phone drives the UI, the check on the server determines the outcome. Having the same check in two places may look like duplication, but it is not — one is there for speed, the other for correctness.
A single write inside a transaction
A Firestore transaction closes exactly this gap. If you do the read inside the transaction and that document changes before the transaction completes, Firebase runs the transaction again from the start. So the second user is re-evaluated against the updated value.
async function hazineTalepEt(hazineId, kullaniciId, talepId) {
const hazineRef = db.collection("hazineler").doc(hazineId);
return db.runTransaction(async (t) => {
const snap = await t.get(hazineRef); // the read is INSIDE the transaction
if (!snap.exists) return { sonuc: "hazine-yok" };
const hazine = snap.data();
if (hazine.kazananId) {
// If the same user retried, produce the same answer
if (hazine.kazananId === kullaniciId) {
return { sonuc: "zaten-senin" };
}
return { sonuc: "baskasi-kazandi" };
}
// The winning decision: one write, one document
t.update(hazineRef, {
kazananId: kullaniciId,
talepId: talepId,
kazanilmaZamani: FieldValue.serverTimestamp()
});
return { sonuc: "kazandin" };
});
}
I paid attention to two things in particular. First, not checking the condition outside the transaction; both the condition and the write are inside the same atomic block. Second, writing the winning decision to a single document. Writing the winner to the treasure document and the user document at the same time and hoping "the two stay consistent" splits the problem you are solving in two. The single source for the winner is the treasure document; the reward record on the user side is a result derived after that write succeeds.
There is a write rate limit on continuously writing to one document; designs where everyone hammers the same row run into it. In the single-winner scenario the contention was short, so it was not a problem, but if you are going to carry the same pattern somewhere like "everyone writes to the same counter", think about that limit first.
The device clock is not evidence
The word "first" carries time in it, so the solution that comes to mind is always the same: put a timestamp on the request and let the smaller one win. If you take that timestamp from the phone, you hand the rule straight to the user. The phone clock can be changed by hand, and time zones, daylight saving and ordinary drift are all in play as well.
In my case the winner is not determined by a timestamp, it is determined by the order of the
transaction. The timestamp exists only for the record and for display on screen, and even
that is written by the server with serverTimestamp(). Even the server timestamp
is not a referee, it is a log entry. Making that distinction shortens the later "why was
this user's claim considered invalid" discussion considerably.
Ordering, reward distribution, deadlines, campaign windows — in none of them should you trust a date field that came from the client. If you need to store it, keep it in a separate field and leave the authority with the server timestamp.
When the same request arrives twice
The most common thing on a mobile network is this: the request reaches the server, the work is done, and the connection drops while the response is coming back. The user sees nothing on screen, so they tap again. The server sees the same request a second time.
In that situation there is nothing that looks like an error on the server side; the work completed successfully, nobody just received the answer. What the user sees is a frozen button. In other words, even when the system is working correctly the user is pushed into retrying. If retrying was not designed as part of the normal flow, the second request produces a new record.
The correct behavior is to produce the same answer the second time too. I built this out of two pieces:
- The client generates a request ID the moment the button is tapped, and sends the same ID on retries.
- If the winner is already this user, the server returns success rather
than an error — the
zaten-seninbranch in the code above.
"You already won this" and "someone else won" are two completely different states. If you throw both into a single error bucket, you end up showing an error to a user who won their own reward. When running an operation twice gives the same result as running it once, that is called idempotency; in practice it means the server can answer the question "have I seen this request before?"
What we tell the user who loses
Once the technical side is solved, the product side is what is left. The user who lost had genuinely walked to that treasure. Telling them "something went wrong" is the worst option; nothing went wrong, the rule did its job.
I changed two things. The first is the message: I say clearly that the treasure was found by someone else, update the map immediately and remove that treasure from the list. The second is that I removed the optimistic UI. Instead of opening the celebration screen straight after the tap, I wait for the server's answer. Confetti fires once and cannot be taken back; saying "actually you did not win" after someone has seen that they won is worse than never having won at all.
Bugs like this are almost invisible when you test by hand on a single device. Write a small script that fires the same request many times in parallel, then look at how many winners ended up written. The answer should be one every time.
The same shape showed up elsewhere too
The treasure hunt was the most visible form of this problem, but not the only one. Prize distribution for a contest in ArenaX, competing offers on the same ticket in 2.El Bilet, the last unit in stock in Cepte Perakende — all of them are different names for the same shape: more than one claim on a limited resource at the same time.
Now, whenever I run into a product rule like this, I ask four questions before writing any code:
- Who makes the decision — the client or the server?
- Are the condition check and the write inside the same atomic block?
- Does the result change if the same request arrives twice?
- Who measures time?
The product sentence is still one line: first to find it wins. Its backend equivalent is these four lines: the server makes the decision, the condition and the write happen in a single transaction, the result does not change if the same request arrives twice, and the server measures time. Underneath rules that look simple there is usually a concurrency question like this one. When that question is not asked, the bug does not disappear — it just waits for the busiest day.
- Firebase
- Firestore
- Concurrency
- Architecture
- Mobile App
Demir Taşdemir
Mobile App & Web Developer
I have been building software since 2018. I have shipped 11 apps on the App Store and Google Play; right now I am working on 6 mobile apps, 1 e-commerce platform and 1 desktop game.
Limited resource, a single winner
If you are building a last-in-stock flow, a single-use coupon or a reward flow with one winner, we can look at the architecture together.