Why the reward pays twice
What you saw
One click: +50. A slow second click: refused. Two fast clicks: +100. The "once a day" rule works whenever the clicks are spaced out and fails whenever they overlap. That speed-dependence is the signature of a race condition.
But the button is the least interesting way to see this, and it's a trap to fix it there. The button only fires the bug when you happen to land two clicks close together — so on a snappy day it can look perfectly fine. The real exploit doesn't involve a button at all:
curl -s -XPOST localhost:4013/api/reset >/dev/null
curl -s -XPOST localhost:4013/api/claim & curl -s -XPOST localhost:4013/api/claim & wait
curl -s localhost:4013/api/account # 100. Five in parallel? 250.
That's the version that matters. A user double-clicks by accident; an attacker fires fifty parallel requests on purpose. Anything reachable over HTTP gets hit concurrently — by retries, by load balancers replaying a request, by two open tabs — whether or not your UI ever lets it happen. Treat the endpoint, not the button, as the thing that has to be correct.
What's actually happening
Here's the claim handler:
app.post('/api/claim', async (req, res) => {
if (account.claimedToday) {
// 1. CHECK
return res.status(409).json({ error: 'already claimed today' })
}
await sleep(40) // 2. slow ledger write
account.balance += 50 // 3. ACT
account.claimedToday = true // (mark claimed)
res.json({ credited: 50, balance: account.balance })
})
Node runs one piece of JavaScript at a time, but await is a yield point: while one request is parked at await sleep(40), the event loop is free to start handling the next request. So with two clicks ~5ms apart:
- Request A checks
claimedToday→false, proceeds, hits theawait, and pauses. - Request B checks
claimedToday→ stillfalse(A hasn't set it yet!), proceeds, hits theawait. - A resumes:
balance += 50,claimedToday = true. - B resumes:
balance += 50again,claimedToday = trueagain.
Result: balance === 100. Both requests squeezed through the door because the check and the act were separated by an await, and "have I claimed?" was answered before "I have now claimed" was written. The window between them is small, but a double-tap (or a flaky network retry, or two browser tabs) lands right inside it.
This is a TOCTOU bug — Time Of Check To Time Of Use. The fact you checked something doesn't help if the world can change between the check and the use.
The fix that isn't: "just make the write faster"
The obvious move is to blame the slow await sleep(40) in the middle and shrink it. Resist it. The delay only controls how wide the window is, not whether there is one — and a narrower window is still a window. Here's the same buggy handler under 5 parallel curl claims at different delays:
handler 5 parallel claims credit
---------------------------- ------------------------
await sleep(40) 250
await sleep(1) 100
await sleep(0) 100 <- still broken!
flag set BEFORE the await 50 <- fixed
Even await sleep(0) loses, because await always yields to the event loop — the second request gets to run its check before the first comes back to set the flag. Shrinking the delay just makes the bug harder to hit by hand, so the button feels fixed while the API stays wide open. (And you usually can't delete the await anyway — in real life it's a genuine database round trip, not a fake one.) The duration was never the bug. The ordering is.
The fix
The core idea: make "decide whether I'm allowed" and "record that I've claimed" a single, indivisible step — and crucially, do the recording before yielding to anything async.
The smallest correct fix here is to claim the slot up front, before the await:
app.post('/api/claim', async (req, res) => {
if (account.claimedToday) {
return res.status(409).json({ error: 'already claimed today' })
}
account.claimedToday = true // 👈 take the slot synchronously, before any await
await sleep(40) // now the slow write can't be double-entered
account.balance += 50
res.json({ credited: 50, balance: account.balance })
})
Because there's no await between the check and setting the flag, no second request can observe claimedToday === false once the first has decided to proceed. (If the ledger write can fail, roll the flag back in a catch so a genuine error doesn't burn the user's daily claim.)
In a real system you'd push the atomicity down to the datastore instead of trusting in-process timing:
- A conditional update that both checks and sets in one statement:
UPDATE accounts SET claimed_today = true WHERE id = ? AND claimed_today = false— then credit only if exactly one row changed. - A unique constraint on
(account_id, claim_date)in a claims table, so a second insert simply errors. - An idempotency key sent by the client and stored, so retries of "the same" claim collapse into one.
All of them replace "check, then later act" with "act in a way that can only succeed once."
The bug class
This is a check-then-act race condition (TOCTOU), and its cure is idempotency / atomicity. People assume "Node is single-threaded, so I don't have races" — but every await is a place where another request can interleave. Concurrency bugs live in the gaps between your awaits, not in threads.
How to spot it in the wild:
- A read, then an
await, then a write that depends on the read. "Check the balance, then await, then deduct." "Look up the row, then await, then insert if missing." That shape is a race waiting for traffic. - It only breaks under load, double-clicks, retries, or two tabs. Bugs that vanish when you step through them slowly and reappear in production are almost always timing/interleaving bugs.
- "Once per X" rules enforced in application code. Once-per-day, one-vote-per-user, claim-a-username, reserve-the-last-seat — uniqueness is best enforced by the database with a constraint or conditional write, not by an
ifcheck sitting above anawait.
The durable habit: whenever correctness depends on "this can only happen once," make the one-time decision atomic — either remove the await between check and commit, or let the datastore be the single source of truth that can only say yes once.