Free preview

Race Conditions and Atomic Counters

In one line: this is a correctness bug, not a performance one, and it fails in the dangerous direction — the limiter lets more traffic through than configured, exactly when traffic is heaviest.

The bug

Counter = 5, limit = 10

Request A          Request B
---------          ---------
read  -> 5
                   read  -> 5
+1    -> 6
                   +1    -> 6
write    6
                   write    6

Two requests served. Counter says 6, should say 7.
The limiter has silently lost a request.

It fails open, and it fails worst under load

Two properties make this worse than a typical race.

It undercounts, never overcounts. A lost increment means the limiter believes a client has used less of its budget than it has, so it permits more traffic than configured. The failure mode is letting traffic through — the opposite of what the component exists for.

Its severity scales with concurrency. Lost updates require overlapping read-modify-write cycles, so they are rare at low traffic and common at high traffic. The limiter is therefore least accurate exactly when it matters most — during the burst it was installed to contain.

And it is silent. Nothing errors; the counter is simply wrong. Unless you are reconciling counted requests against served requests, you will not know.

This is the classic lost-update problem the databases material described for concurrent writes, and it is worth recognizing it as the same bug rather than a rate-limiter-specific quirk.

Why locking is the wrong first answer

Locking prevents this but creates performance bottlenecks.

A lock on the counter puts a critical section on every request

The counter is touched by every request for that client, so locking it makes the hottest path in the system serialized.

This is exactly the failure message-queue design described for single-server queues: the structure becomes a critical section, and adding concurrent clients adds contention rather than throughput. Under the burst the limiter exists to handle, lock contention degrades latency for everyone — including the requests that were comfortably within their limits.

You have traded a correctness bug for a latency bug on 100% of traffic, which violates Lesson 2's low-latency requirement.

But the design adds an important qualification:

Note: locking is not inherently bad. If lock contention is low, performance impact is minimal. For high contention, consider sharding data or using fine-grained locks.

This qualification is worth taking seriously

"Never use locks" is folklore, not engineering. A lock is only a problem when it is contended, and contention depends on how many threads want the same lock at the same time.

Per-client counters are naturally fine-grained: two different clients touch two different counters and never contend. The problem only appears for a single hot client with high concurrency — which is a much narrower case than "locking is slow."

So the honest position is: locks are fine per-client; they hurt on hot keys. For those, shard or go lock-free. Being able to say when a simple solution is adequate is a stronger signal than reflexively reaching for the sophisticated one.

The fix: atomic operations

A better approach is to use atomic operations (like Redis INCR) which increment and return the value in a single step.

Counter = 5, limit = 10

Request A              Request B
---------              ---------
INCR -> 6
                       INCR -> 7

One round trip each. No read-modify-write window.
Counter = 7. Correct.

Atomicity removes the window rather than guarding it

A lock protects the read-modify-write window by making others wait. An atomic increment eliminates the window — there is no interval during which another request can observe a stale value, because read and write are one indivisible operation.

That is why it is faster as well as correct: nothing waits. Redis's single-threaded execution model, from distributed caching, is what makes INCR atomic without locking — commands execute one at a time by construction.

Practical note worth mentioning: you usually need INCR plus EXPIRE together — increment the window's counter and set its TTL so it resets. Doing those as two commands reintroduces a race on the first request of a window, which is why real implementations use a Lua script or SET NX to make the pair atomic. Knowing that detail signals you have built one.

The direction of the error is what makes this dangerous rather than merely wrong: a lost increment means the limiter believes a client has used less of its budget than it has, so it fails open — permitting traffic under exactly the concurrency that produced the bug.

Sharded counters

Sharded counters can also be used to distribute write contention across multiple keys.

Note: sharded counters reduce write contention by splitting the counter into multiple parts. However, reading the total count requires summing all shards, which may slightly increase read latency.

Instead of:  client:101:count           <- every request contends here

Use:         client:101:count:shard0
             client:101:count:shard1
             client:101:count:shard2
             client:101:count:shard3

Write: INCR a randomly chosen shard   -> contention divided by 4
Read:  SUM all four shards            -> 4 reads instead of 1

Sharded counters trade read cost for write throughput — and that trade fits rate limiting well

The pattern converts one hot key into N warm ones, dividing write contention by N. The cost is that a read must fan out and sum.

That happens to suit rate limiting, because the read-to-write ratio is favourable: every request writes, but you only need the exact total when you are near the limit. Well below the limit, an approximate sum is sufficient; the precision only matters at the boundary.

It is also a nice example of the recurring hot-key theme. The Distributed Cache chapter split a hot key across servers; message-queue design split a hot partition key. Same technique, third context: when one key is too hot, make it several keys and pay to recombine them.

the sharded-counters pattern in this course is devoted entirely to sharded counters, so this is a preview of a building block rather than a one-off trick.

Key takeaway

Read-modify-write undercounts under concurrency, which means the limiter permits more traffic than configured — and does so worst under load. Atomic operations like INCR remove the window rather than guarding it. Locks are acceptable when uncontended — which per-client counters usually are — and sharded counters handle the hot-key case by trading read cost for write throughput.

Interview signal by level

LevelWhat a strong answer sounds like
L4"Use a lock so two requests don't update the counter at the same time."
L5Reaches for atomicity: "read-modify-write loses updates under concurrency, so use an atomic INCR instead — one operation, no window for another request to read a stale value."
Staff+Names the failure direction and the hot-key case: "the race undercounts, so the limiter lets more through than configured — and it's worst under exactly the burst we installed it for. INCR fixes it, though you need INCR and EXPIRE atomically or you race on the first request of each window. Locks are actually fine when uncontended, which per-client counters usually are; for a genuinely hot client I'd shard the counter and pay a fan-out read."

Next: getting the counter update off the critical path entirely.

Enjoying the preview?

Create a free account to unlock the rest of this course, the in-browser judge, and live AI mock interviews.

Sign up free to continue