Back to the crumb
The lesson
nodeChewy~25 min

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:

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:

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:

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.