10 min read

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:

#
Request A
Request B
coupon.uses_left
1
read uses_left → 1
1
1 / 8

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.

ProblemFix
Read → write on a counter or balanceOne 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 objectOptimistic locking (WHERE version = $v), or SELECT … FOR UPDATE in a transaction
Retried or redelivered requestsIdempotency keys: store the key with the result, return the stored result on repeat
Concurrent cache missesSingle-flight: share one in-flight promise per key
Exclusive jobs across instancesA lock in the DB or Redis, with an expiry, released in finally
OrderingVersion numbers or sequence numbers. Ignore anything older than what you have.
Work that must not be lostA durable queue or outbox, not a floating promise

Part 3: The drills

Drill 1: Limits and "once only"

Bug hunt
Coupon redemption
2 bugs planted. Click every line you think is wrong, then check.
0:00 / 4 min

Drill 2: State shared between requests

Bug hunt
Server state
3 bugs planted. Click every line you think is wrong, then check.
0:00 / 4 min

Drill 3: Promises

Bug hunt
Notifications and dashboards
5 bugs planted. Click every line you think is wrong, then check.
0:00 / 5 min

Drill 4: Retries, duplicates and timeouts

Bug hunt
Payments worker
4 bugs planted. Click every line you think is wrong, then check.
0:00 / 5 min

Drill 5: Caches, ordering and clocks

Bug hunt
Prices and stock
3 bugs planted. Click every line you think is wrong, then check.
0:00 / 4 min

Drill 6: Process-level failures

Bug hunt
Background work
3 bugs planted. Click every line you think is wrong, then check.
0:00 / 4 min

Part 4: Time is a concurrency problem too

Some L4 bugs involve only one request, but time passing:

BugWhat to look for
No timeoutAny network call without one: the request waits forever and holds resources
Timeout that doesn't cancelPromise.race, or a timeout around work that keeps running
Clock skewComparing timestamps from different machines
Ordering assumptions"Created always arrives before updated"
Expiry racesChecking "is it valid?" then using it later, after it expired

Cheat sheet

SignalBugFix
Read, await, writeLost update / double spendAtomic conditional UPDATE, or a lock
"If not exists, insert"DuplicatesUnique constraint
Module-level let or Map in a serverCross-request leaks, per-instance limitsRequest context, shared store
forEach(asyncUnawaited work, unhandled rejectionsfor…of or allSettled
Promise.all(items.map(call))Unbounded parallelism or fail-fastConcurrency limit, allSettled
An async call with no awaitLost work, crashesAwait, or a durable queue
Retry around a POSTDuplicate side effectsIdempotency key
Webhook / queue handlerDuplicate deliveriesDedupe by event ID
Promise.race timeoutWork continues after "timeout"AbortSignal
Cache miss → fetchStampedeSingle-flight
A boolean "busy" flagStuck forever on error; no cross-instance effectfinally + 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