A Race Condition and a Bad Decision

Web · Apr 20, 2026 · 1 min read
#race-condition#logic-flaw

The coupon endpoint did the sensible thing in the wrong order: it checked whether the coupon had been used, then — some milliseconds later — marked it used. Between those two steps lived a window, and windows are for climbing through.

The window

The check and the write were not atomic. Fire two requests close enough together and both pass the “has this been redeemed?” test before either performs the “mark redeemed” write. Both proceed. The coupon is applied twice.

The math of it

If a single redemption’s check-to-write gap is tgapt_{\text{gap}} and you fire nn requests within a burst of width ww, the expected number that land inside the same window is roughly

E[winners]ntgapwE[\text{winners}] \approx n \cdot \frac{t_{\text{gap}}}{w}

so the attack is not “get lucky once” — it is “reduce ww until winning is the default.” A tight burst over HTTP/2 made ww small enough that two-for-one was reliable, not lucky.

The fix

Make the decrement atomic and conditional: a single UPDATE ... WHERE remaining > 0 (or a unique constraint on (coupon, user)) collapses check-and-write into one indivisible step. Everything softer than that is just a slower race. It was staging. It was still embarrassing for someone.

all writeups rss