The job was small. Give a new account ten free messages, for life, without asking for a card. Count them, and when the tenth is gone, stop.

The counting is the whole product decision. Ten is a number chosen so that the worst case a stranger can cost is a few cents. If the tenth message is not actually the last one, the number stops meaning anything and the tier stops being a bounded promise.

The shape of the gate

Reading a counter and then writing it back is the textbook race. Two requests read nine, both decide there is room, both write ten, and eleven messages have been served. So the read and the write go inside a transaction:

let allowed = false;

await db.runTransaction(async (txn) => {
  const snap = await txn.get(userRef);
  const used = snap.data()?.freeMessagesUsed ?? 0;
  if (used >= FREE_MESSAGE_LIMIT) return;
  txn.set(userRef, { freeMessagesUsed: used + 1 }, { merge: true });
  allowed = true;
});

if (allowed) return null;
return Response.json({ error: 'free_limit_reached' }, { status: 402 });

That reads as correct. The read and the increment are atomic. The counter cannot be double counted. Under contention Firestore will serialise the writers and the number will come out right.

The number does come out right. It is the answer that does not.

What the test found

Twelve concurrent requests, fired at an account whose ten messages were already gone. The correct outcome is that all twelve are refused.

Nine were served.

The stored counter, checked afterwards, read exactly ten. It had never gone above ten. It was never wrong at any point during the run.

Where the nine came from

Firestore retries a contended transaction. That is not an error path, it is the normal mechanism: when the data a transaction read has changed underneath it, the transaction is run again with the new data. The callback you handed it is not a description of work to be done once. It is a function that may be called several times.

The retry is complete as far as the database is concerned. The reads are redone, the writes are discarded and reissued, and the committed result is consistent.

None of that extends to allowed, because allowed is not in the database. It lives in the closure, and the closure is not part of the transaction.

So the sequence that served a message it should not have was:

  1. An attempt runs while there is still room. It sets allowed = true.
  2. Contention. Firestore discards that attempt’s writes and runs the callback again.
  3. The retry reads a count that is now at the limit, hits the early return, and writes nothing.
  4. The transaction commits. The database is perfectly correct.
  5. allowed is still true, left there by step one.

Everything the transaction promised was delivered. The counter was protected. The write was atomic. The variable carrying the decision was neither of those things, and it was the variable the code actually read.

The fix is one line

await db.runTransaction(async (txn) => {
  allowed = false;
  const snap = await txn.get(userRef);
  ...
});

Reset it at the top of every attempt, so that no attempt can inherit a previous attempt’s conclusion. The rule generalises past this one flag: anything mutated outside a transaction callback must be initialised inside it, because you do not control how many times the callback runs.

The safest version of the rule is to have nothing to reset. Return the decision from the transaction rather than assigning it out through a variable, and the bug becomes unexpressible. The one line was the smaller change to a gate already in front of paying traffic, and the reason it is a line rather than a refactor is written above it in the file.

Two smaller things in the same block

The catch around the transaction sets allowed = false as well. A database outage has to close the gate rather than open it. It is a small decision that picks a side in advance: when the checking machinery is broken, everybody is refused instead of everybody being served. That is an easier failure to explain to one customer than to an invoice.

The write is set with merge rather than update, because a new account can reach the chat before onboarding has created its record, and update throws on a document that does not exist. That one is not hypothetical. It is how a real paying customer ended up with no plan.

What I would take from this

The counter is the thing that feels valuable, so the counter is what the transaction was put around. But nobody is billed by the counter. They are served or refused by a boolean sitting outside it, and that boolean had no protection at all.

A transaction gives you an atomic read and write. It does not give you an atomic function call. When the useful output of a transaction is a decision rather than a number, the decision has to come out through the return value, because that is the only channel with the same guarantees as the data.

The other half is that none of this was visible by reading it. The code looked right, and it was right about the thing it was written to be right about. It took firing twelve requests at it at once to find out that the correct number and the correct answer were two different questions.