Build1 publisher3 min readPublished
Flash-sale seat booking in Redis needed four fixes beyond one atomic Lua script
Every booking in a flash-sale seat design on dev.to runs through one atomic Redis Lua script, yet the counter still needed four separate fixes. The last one holds each booking until a Redis replica confirms the write.
The Engineer · Build desk

What happened
- Every booking passes through one Redis Lua script that checks prior admission, the level cap and the global cap, then counts the seat, all as one atomic step.
- A pending-admissions list plus a cleanup job returns seats from crashed requests that never reached the database.
- Bookings count only after WAIT 1 200 shows the Redis replica has the write; otherwise the booking is undone and the client retries.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- decision Teams copying the WAIT step have to copy the rollback guard with it, because every unconfirmed booking goes down the undo path and a repeated undo would release the seat twice.
- cost Each admission waits on a replica acknowledgement before it counts, and a missed timeout costs the candidate a retry at the moment of peak demand.
- constraint Correctness rests partly on one Redis setting: an operator who switches to an evicting memory policy lets Redis drop adm:seen and reopens the door to duplicates.
- exposure With no database fallback, any valid code missing from the pre-event allowlist load is rejected with 400 invalid_code for the whole sale.
Lua won on contention. According to the post, MULTI/WATCH caused too many retries when many requests competed, and distributed locks added round trips while the lock was held [2]. The order of the checks inside the script matters. The duplicate test runs first. If the code is already admitted and its stored pending entry carries the same nonce, the script returns the original slot [14]. Any other request from an admitted code gets -1, a full level gets -3, and a sold-out event gets -2 [14]. The nonce match runs before the cap checks, so a retried request whose level filled in the meantime still gets its seat back [14].
The post lists four ways the counter can still be wrong after all that [3]. The script is atomic only for what happens inside Redis during the call. The four failures happen around the call. A client that never saw its reply retries with the same nonce [5]. A request that crashed after admission leaves an entry on the pending list, and a cleanup job returns seats that never reached the database [6]. That job exists for the third of the post's three invariants: every admitted seat ends up paid or released, and none stays counted with nobody holding it [8].
For failover, the service calls `WAIT 1 200` and counts a booking only after the Redis replica confirms it has the write [7]. If the confirmation does not come, the booking is undone and the client retries [7]. I think this fix only works because of the rollback guard. The failover path ends in an undo, and the guard makes a second run of that undo do nothing [4]. Without it, a timed-out WAIT followed by a retried undo would be the double rollback the post lists first among its failures [3].
Two settings sit under the script. Candidate codes are trimmed and uppercased before any lookup, and a SHA-256 hash of the normalised code is the database key [10]. Without that step the counter would treat `ab123` and `AB123` as two people [10], and the sale would favour candidates who know where the caps-lock key is. The second setting is `maxmemory-policy noeviction`. When memory runs low, Redis refuses writes with an error, so it can never delete `adm:seen` to free space [9]. Losing that set would allow duplicates, while a refused write becomes a 503 the client can retry [9].
The read-only gates in front of the script reject most invalid traffic before any write happens [11]. The allowlist is loaded into Redis from the back-office database before opening [11]. There is no database fallback during the run, because a missing code must not turn into a database query at 50,000 requests a minute [11]. Spread evenly, that is about 833 requests a second [16]. A hidden honeypot field is checked before everything else. A bot that fills it gets a fake success response with no admission and no log entry [12].
The pattern transfers to sales where caps and the allowlist are fixed before the doors open. The post keeps the gates outside the script because their values change only when an operator changes them before the event, and it puts every check that competes at booking time inside the script [13].
What to watch
- Part 3 of the series, on what happens after a seat is admitted, should show how admitted seats are finally marked paid or released.
- How often WAIT misses its 200 timeout at peak load, since each miss becomes an undo plus a client retry.
- The cleanup job's schedule: a seat held by a crashed request stays counted until the job returns it.