L4 · Concurrency and Time
Each request is correct on its own. Run two at once, retry one, or deliver one twice, and it isn't.
This chapter is the bug-hunting summary. JS Code Review covers the event loop, races and async patterns in depth, with simulations.
The story
A food-delivery app launches a coupon: ₹200 off, 500 redemptions, once per customer. The redemption code checks both rules before applying the discount, and the tests confirm both.
By the end of the day the coupon has been redeemed 1,140 times, and one account used it 37 times. A deal-sharing forum had found that tapping "Apply" rapidly, or running a short script that sends 50 requests at once, made the checks pass every time.
Here's what two requests did, one step at a time:
Neither request did anything wrong. The bug only exists between them. That's why L4 bugs survive testing: almost every test runs one request at a time.
Part 1: The question to ask at every await
In Node, nothing else runs in the middle of synchronous code. Everything else can run at an await (and across processes and machines, at any moment). So the core L4 technique is mechanical: mark every await and ask:
What else could run here, and what could it change?
Four shapes cover most L4 bugs:
Part 2: The toolbox of fixes
Knowing the fix lets you judge the code faster: if the right tool is missing, there's probably a bug.
| Problem | Fix |
|---|---|
| Read → write on a counter or balance | One atomic statement: UPDATE … SET n = n - 1 WHERE n > 0, then check rowCount |
| "Only once" / "only if not exists" | A unique constraint, and handle the violation |
| Read-modify-write of a whole object | Optimistic locking (WHERE version = $v), or SELECT … FOR UPDATE in a transaction |
| Retried or redelivered requests | Idempotency keys: store the key with the result, return the stored result on repeat |
| Concurrent cache misses | Single-flight: share one in-flight promise per key |
| Exclusive jobs across instances | A lock in the DB or Redis, with an expiry, released in finally |
| Ordering | Version numbers or sequence numbers. Ignore anything older than what you have. |
| Work that must not be lost | A durable queue or outbox, not a floating promise |
Part 3: The drills
Drill 1: Limits and "once only"
Drill 2: State shared between requests
Drill 3: Promises
Drill 4: Retries, duplicates and timeouts
Drill 5: Caches, ordering and clocks
Drill 6: Process-level failures
Part 4: Time is a concurrency problem too
Some L4 bugs involve only one request, but time passing:
| Bug | What to look for |
|---|---|
| No timeout | Any network call without one: the request waits forever and holds resources |
| Timeout that doesn't cancel | Promise.race, or a timeout around work that keeps running |
| Clock skew | Comparing timestamps from different machines |
| Ordering assumptions | "Created always arrives before updated" |
| Expiry races | Checking "is it valid?" then using it later, after it expired |
Cheat sheet
| Signal | Bug | Fix |
|---|---|---|
Read, await, write | Lost update / double spend | Atomic conditional UPDATE, or a lock |
| "If not exists, insert" | Duplicates | Unique constraint |
Module-level let or Map in a server | Cross-request leaks, per-instance limits | Request context, shared store |
forEach(async | Unawaited work, unhandled rejections | for…of or allSettled |
Promise.all(items.map(call)) | Unbounded parallelism or fail-fast | Concurrency limit, allSettled |
An async call with no await | Lost work, crashes | Await, or a durable queue |
| Retry around a POST | Duplicate side effects | Idempotency key |
| Webhook / queue handler | Duplicate deliveries | Dedupe by event ID |
Promise.race timeout | Work continues after "timeout" | AbortSignal |
| Cache miss → fetch | Stampede | Single-flight |
| A boolean "busy" flag | Stuck forever on error; no cross-instance effect | finally + a real lock |
Say it in the interview
"Line 2 reads the coupon, line 3 checks the remaining uses, and line 6 writes the decrement after two more awaits. Two concurrent redemptions both read 1, both pass, and both write 0. The fix is one conditional update,
WHERE uses_left > 0, and checking it changed a row. For once-per-customer I'd add a unique constraint on user and code, because an app-level check has the same race."
Go deeper
- JS · The Event Loop Under Pressure and JS · Race Conditions: the mechanics, with simulations.
- JS · Controlling Concurrency: limits, pools and backpressure.
- SD 7 · Fault Tolerance and Idempotency: idempotency keys in full.
- JS Gym: runnable race-condition exercises.