Coordination & Limits

Thundering Herd

One event wakes ten thousand waiting tasks and every one of them does the same thing to the same resource in the same millisecond. The trigger is usually benign — a cache entry expiring, a connection restored, a scheduled job at :00 — and the response is always the same shape: spread it, batch it, bound it, or do it once for everyone.

▶ Run the lab

The question this answers

The question

Why does one small event produce a load spike ten thousand times its size, and how do I stop the crowd forming?

The work

10,000 in-flight requests waiting on a cached homepage payload whose TTL expires at exactly 12:00:00, plus 4,000 clients that reconnect simultaneously when a websocket gateway restarts.

What is shared

The cache entry everyone is reading, the origin that regenerates it, and the connection pool between them. The herd itself is emergent: nobody coordinates it, and the coordination is the problem — everyone arrives at once because everyone was released at once.

The invariant — what must stay true under every interleaving

Concurrent demand on the shared resource stays bounded regardless of how many tasks are waiting, and the work behind a single event is performed once rather than once per waiter.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

Ten thousand waiters, one release, one millisecond

A herd forms whenever a large number of tasks are blocked on the same condition and that condition becomes true for all of them at once. The classic sources are all mundane: a cache key expiring, a lock being released with many waiters, a database coming back after a failover, a gateway restarting so every client reconnects, and a cron schedule that a thousand instances all interpret as exactly :00:00.

What makes it dangerous is that the arrival pattern, not the total work, is the problem. Ten thousand cache regenerations spread over sixty seconds is nothing; ten thousand in one millisecond exhausts the pool, saturates the origin, and pushes every request past its timeout — after which the clients retry and it happens again, larger. The system has a stable state and a collapsed state, and the herd is the kick that moves it from one to the other.

The specifically cache-shaped version of this is a stampede, and the performance domain covers its diagnosis in cache-stampede. This lesson is about the general shape and the coordination primitives that address it.

A TTL expiry at t=0. Same total work, three arrival patterns. Spans are shape, not measurement.ILLUSTRATIVE
No mitigation — 10,000 misses at once
10,000 simultaneous misses → origin
timeouts fire; clients retry; second herd
Jittered TTL — expiry spread over 60 s
misses trickle in: ~170/s, absorbed
Single-flight — one fetch, 9,999 subscribers
first miss starts ONE origin fetch
steady state
Origin concurrent requests
herd: ~10,000 offered, pool-capped, queue growing
jitter: ~1 at a time
single-flight: exactly 1
↑ TTL expires↑ timeouts → retry wave
runningreadywaitingblockedidle1 tick ≈ 250 ms

The schedule, and why the retry makes it worse

The step that turns an overload into an outage is the retry. Every one of the ten thousand requests has a client with a retry policy, and those policies were written independently by people who never imagined ten thousand of them firing together. Without jitter on the retry, the second wave is not merely as large as the first — it is *more* synchronised, because the timeouts all fired at the same moment.

That is the general principle behind every mitigation here: desynchronise, or deduplicate. Jitter desynchronises by spreading arrivals in time. Single-flight deduplicates by making the work happen once. Batching does both. A concurrency limit does neither but caps the damage. They compose, and in a serious system you use several.

Cache expiry, herd, timeout, retry. The second wave is more synchronised than the first.ILLUSTRATIVE
Invariant · Concurrent origin requests stay bounded, and one expiry causes one regeneration
#Cache10,000 requestsOrigin serviceClients (retry policies)State
1key homepage:v3 expires at 12:00:00.000···entry=absent originConc=0 served=all
2·all 10,000 in-flight requests miss within ~2 ms··entry=absent originConc=10000 served=none
3··accepts what the pool allows; 9,980 queue for a connection·entry=absent originConc=20 queued=9980
✕ One expiry produced 10,000 identical regenerations of the same value. 9,999 of them are pure waste even if they all succeed.
4··p99 crosses the 2 s request timeout; queued requests start failing·entry=absent originConc=20 queued=9980 failing=yes
5···retry policies fire — most with a fixed backoff, no jitterentry=absent originConc=20 queued=19000
✕ The retry wave is larger AND more synchronised than the original, because every timeout expired at the same instant. The system cannot drain and will not recover on its own.
6a regeneration finally succeeds and fills the key···entry=present originConc=20 queued=12000
Ten thousand waiters produced ten thousand identical requests for one value. Single-flight would have made it one; jittered TTLs would have spread the expiry across a minute; a concurrency limit would have capped the offered load at something the origin could serve. Add jitter to the *retries* as well, or every recovery attempt re-forms the herd.

Four mitigations, and what each one costs

These are not alternatives to choose between — they address different parts of the problem and compose well. Jitter prevents the herd from forming; single-flight collapses it after it forms; batching amortises it; a concurrency limit contains whatever gets through. A cache in front of a slow origin usually wants all four.

The one to be careful with is the concurrency limit on its own. It caps offered load, which prevents the origin from falling over, but the excess has to go somewhere: it waits, or it is rejected. Waiting without a bound is how a queue becomes the new failure (Bounded vs Unbounded Queues); rejecting is honest and requires the caller to handle it. Decide which, explicitly.

MitigationMechanismReducesCost / caveat
Jitter on TTLsRandomise expiry: ttl * (0.8 + random() * 0.4)Arrival synchronisationEntries live slightly longer or shorter than configured; not applicable when the invalidation is event-driven
Jitter on retriesRandomised (ideally full-jitter) exponential backoffThe second and every later waveLonger worst-case recovery for an individual client; must be in every client, and usually is not
Single-flightOne in-flight fetch per key; everyone else subscribesDuplicate work, from N to 1A shared failure: everyone gets the same error. Needs careful cleanup (Single-Flight Coalescing)
Early / probabilistic refreshRefresh before expiry, one lucky request at a timeMisses entirely — the entry never goes coldExtra background load, and a stale window while refreshing
BatchingCollect requests for a few ms and issue one combined callRequest count, from N to N/batchAdds the batch window to every request's latency; needs a batch-capable downstream
Concurrency limit / semaphoreAt most K concurrent calls to the resourcePeak offered loadThe excess waits or is rejected — pick one; an unbounded wait queue is the next incident
Serve stale while revalidatingReturn the expired value, refresh in the backgroundThe user-visible impact of a missStaleness becomes a product decision, not an implementation detail
Desynchronise schedulesPer-instance offset instead of :00:00Cron and heartbeat herdsJob start times become non-deterministic, which complicates reasoning about ordering
Herd mitigations: what each one actually does, and what it costs.

Key points

  • A herd is an arrival-pattern problem, not a total-work problem: the same work spread over a minute is invisible and in one millisecond is an outage.
  • The triggers are mundane — a TTL expiring, a lock released, a gateway restarting, a cron at :00:00, a failover completing.
  • Retries without jitter make the second wave larger and *more* synchronised than the first, which is why the system does not recover on its own.
  • Two families of fix: desynchronise (jitter, staggered schedules, early refresh) or deduplicate (single-flight, batching).
  • A concurrency limit contains the damage but does not remove the demand — decide explicitly whether the excess waits or is rejected.
  • Jitter belongs on the retry path as much as on the TTL; most retry policies in most clients have none.
  • These mitigations compose, and a cache in front of a slow origin usually wants several of them.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • Many tasks block on the same condition — a key's presence, a lock, a connection, a clock.
  • The condition becomes true for all of them simultaneously, and every waiter is made runnable in the same instant.
  • Each one independently performs the same next action against the same resource, so offered concurrency jumps from near zero to N.
  • The resource's queue forms; service time degrades; requests approach and then exceed their timeouts.
  • Timeouts fire — all at roughly the same moment, because they all started at the same moment — and clients retry.
  • The retry wave arrives even more tightly synchronised than the original, and the resource has not drained, so the second collapse is faster than the first.
  • Recovery requires either external intervention, load shedding, or one request finally succeeding and refilling whatever everyone was waiting for.
Interleavings that matter
  • 12:00:00.000 the key expires; by 12:00:00.002 all 10,000 in-flight requests have missed; the pool admits 20 and 9,980 queue for a connection.
  • At 12:00:02 the request timeout fires for the queued requests — all of them within a few milliseconds of each other, because they all started together — and every client retries.
  • The retry wave arrives at 12:00:02.5, 19,000 strong and more tightly clustered than the first; the origin has not drained a single request.
  • With single-flight: the first miss starts one origin fetch and 9,999 later misses attach to the same in-flight handle; the origin sees exactly one request; the result is broadcast and the cache is filled.
  • With jittered TTLs: expiries are spread over 60 seconds, so the origin sees roughly 170 regenerations per second, which is ordinary traffic.
  • Lock release with 500 waiters: all 500 become runnable, all 500 contend for the same lock, 499 immediately block again — a wake storm that costs 500 context switches to accomplish one acquisition (What Contention Actually Costs).
  • Gateway restart: 4,000 websocket clients reconnect within a second with no backoff, and the authentication service — which was never the problem — is the component that fails.
What it guarantees — and does not
  • Guaranteed: jitter spreads arrivals in time. It does not reduce total work by a single unit.
  • Guaranteed: single-flight reduces concurrent duplicate work for one key to one, for as long as the in-flight handle exists.
  • Guaranteed: a concurrency limit caps offered load at K, whatever the demand.
  • NOT guaranteed: that a concurrency limit protects you. The excess still exists — as a queue that can grow without bound, or as rejections.
  • NOT guaranteed: that jitter helps an event-driven invalidation. If the trigger is a publish rather than a clock, there is no TTL to randomise.
  • NOT guaranteed: that single-flight helps across processes. It is per-process unless the coordination is shared, and twenty instances means twenty flights (A Mutex on Server A Does Nothing About Server B).
  • NOT guaranteed: recovery once retries are compounding. The system may need shedding to return to a stable state.
Where contention appears
  • The awaited resource is contended by every waiter simultaneously — a pool, an origin, a lock, an auth service.
  • Wake-up itself is contention: N runnable tasks contend for cores and for the cache lines they are all about to touch.
  • On a lock, the herd produces N−1 wasted wakeups per release, each one a context switch that accomplishes nothing (Lock Convoys).
  • The queue in front of the resource is the second contention point, and an unbounded one converts an overload into a memory problem.
  • Retries add contention precisely when the resource has the least capacity to absorb it.
How it fails
  • Cache stampede: N identical regenerations of one value, of which N−1 are pure waste.
  • Pool exhaustion, with the blast radius covering every endpoint sharing the pool (Parallelism Moves the Load Downstream).
  • Retry storm: successive waves larger and more synchronised than the first, preventing recovery (Retry Storms: The Load You Generated Yourself).
  • Wake storm on a lock or condition variable: N wakeups, one winner, N−1 immediate re-blocks.
  • Reconnect storm after a restart, where the authentication or session service fails rather than the gateway that restarted.
  • Cron convergence: a thousand instances all scheduled at :00:00 producing a load spike every hour, on the hour, forever.
  • Self-inflicted synchronisation: a fix that resets all TTLs at once — such as a cache warm-up on deploy — recreating the herd on a new schedule.
When it helps
  • Nothing about a herd helps, but the *waiting* usually does: many tasks blocked on one condition is a normal, efficient design.
  • A deliberate herd is occasionally correct — releasing all participants at a barrier, or starting a load test with a synchronised start latch.
  • The mitigations help most where the awaited work is expensive and identical across waiters, which is the cache case exactly.
  • Jitter is nearly free and should be the default on every TTL and every retry policy, whether or not you currently have a herd.
When it hurts
  • Any time the waiter count is large and the awaited resource has a hard capacity ceiling.
  • Any time retries are synchronised, which is the default in most clients unless someone explicitly added jitter.
  • Any scheduled work expressed as an exact clock time across many instances.
  • Any invalidation that clears many keys at once — a deploy-time cache flush is a herd generator with excellent intentions.
  • Any system with an unbounded wait queue, where the herd becomes memory exhaustion rather than rejection.
How you would know
  • Concurrent requests to the resource at the moment of the event — a spike from ~1 to N is the whole diagnosis.
  • Cache miss rate as a time series with sub-second resolution; a stampede is a single-bin spike that a 60-second average erases completely.
  • Ratio of origin requests to cache misses. Single-flight should drive it far below 1; if it is 1, the coalescing is not working.
  • Retry counts as a proportion of total requests, and the inter-arrival distribution of retries — clustering is the signal that jitter is missing.
  • Pool acquisition wait time at p99 during the event (Connection Pool Saturation: Waiting in Front of an Idle Database).
  • For scheduled work: a histogram of job start times. Every instance at :00:00.000 is visible instantly and fixed with a per-instance offset.
Complexity it introduces
  • Jitter makes behaviour non-deterministic, which is exactly the point and does complicate reproducing a bug.
  • Single-flight adds an in-flight map with a lifecycle, a dedup key design and a failure policy (Single-Flight Coalescing).
  • Batching adds a window that must be tuned and that appears in every request's latency budget.
  • Concurrency limits add a bound to justify and a rejection path the caller must handle.
  • Several mitigations composed means several interacting parameters, and their interaction is the thing nobody documents.
Simpler alternatives
  • Never let the entry go cold: refresh in the background before expiry, so there is no miss to stampede on.
  • Serve stale while revalidating, which removes the user-visible impact and turns staleness into an explicit product decision.
  • Make the work cheap enough that N copies do not matter — sometimes the real fix is a faster origin, not less concurrency.
  • Push the value instead of pulling it: publish updates into the cache so it is never invalidated by a clock at all.
  • A shared coordination point (a distributed lock or a leader) when single-flight must span processes — with the warning in A Mutex on Server A Does Nothing About Server B very much attached.
  • Load shedding at the edge, which is the honest answer when demand genuinely exceeds capacity and no amount of spreading changes that.

Thundering herd: 10,000 waiters

Thundering herd — one event, ten thousand waiters
A cache entry expires (or a leader is elected, or a socket becomes readable) and every one of 10K waiters wakes at once and hits the same resource, which serves 900/s.
t = 0— — capacity per 100 ms buckett = 3000 ms
requests issued
10K
peak arrivals in one bucket
10K
shed / rejected
9,640
queue drained by
300 ms
no mitigation   10K requests in one 100 ms bucket vs a capacity of 90/bucket → 9,640 shed
this setting    10K requests spread over one instant → peak 10K/bucket, 9,640 shed

jitter    sleep(base + random() * window)  — decorrelates wakeups; costs a little latency
batching  one call serves N waiters        — cuts the request count, not the wakeup count
both      are cheaper than the capacity you would otherwise have to buy for one instant per hour
9,640 of 10K waiters get nothing. Peak demand is 10K in a 100 ms bucket against a capacity of 90. The resource is not undersized for the average load — it is undersized for one instant, and that instant is created by the fact that every waiter was released by the same event. Note what does not fix this: retries. A client that retries immediately after being shed lands inside the same spike and makes the second peak worse than the first. Spread the wakeups (jitter) or reduce them (batch, or coalesce with single-flight).
SIMULATEDqueue bounded at 4× capacity; overflow is shed

Single-flight: N callers, one call

Single-flight — N callers want the same key
64 requests for the same cache key arrive while it is being recomputed. Coalescing lets the first one do the work and parks the rest on its result.
Callers
1 leader issues the call · 63 followers park on its promise
Downstream
1 call against a service sized for 20 concurrent
downstream calls
1
work saved
63 calls (98.4%)
caller latency
60 ms
callers that see an error
0
without    64 callers →  64 downstream calls   latency 192 ms  (queued behind each other)
with       64 callers →   1 downstream call    latency 60 ms  (everyone waits for the leader)

failure   one attempt, 64 disappointed callers — the blast radius of a single bad call is now N
retry     the followers cannot retry independently; they only ever saw the leader's outcome
64 callers, 1 downstream call, 98.4% of the load gone. The mechanism is a map from key to in-flight promise: the first caller creates the entry and does the work, everyone else finds it and awaits it, and the entry is removed when it settles. What you buy is load reduction; what you pay is coupling — every caller now has the latency and the fate of the leader, and a slow leader makes all 64 slow. Flip the failure toggle to see the sharp edge.
attempts against the origin this window: 1
SIMULATED

Bounding concurrency with permits

Bounding concurrency — the permit count protects the dependency, not you
10K tasks behind a semaphore. The downstream service can serve a fixed number at once; the permit slider decides how many you throw at it.
permitsgoodputmean latencytimeoutsfailed of 10K
1 25/s43 ms0.00%0
5 125/s43 ms0.00%0
10 250/s43 ms0.00%0
25 625/s43 ms0.00%0
50 1000/s53 ms0.00%0
100 1000/s103 ms0.00%0
200 1000/s203 ms0.00%0
350 1000/s353 ms0.00%0
500 0/s503 ms100.0%10K
in flight
50
goodput
1000/s
queueing delay added
10 ms
tasks that time out
0
50 permits against a dependency that serves 40 at a time. The extra 10 requests are not being served faster — they are sitting in the dependency's queue adding 10 ms to every latency, and 0 of the 10K tasks time out because of it. Goodput is 1000/s against a peak of 1000/s: you added concurrency and got errors, not throughput. The permit count you want is the one that keeps in-flight work at the dependency's capacity — which you measure, you do not guess.
SIMULATED40 ms service · 400 ms client timeout

Eight threads, one lock

Eight threads, one lock
Every thread does some work, then takes the same mutex. Watch how much of each lane is spent waiting for a turn, and what the machine actually delivers.
8 cores
Thread 1
work
lock
work
wait
lock
work
Thread 2
work
wait
lock
work
wait
lock
work
Thread 3
work
wait
lock
work
wait
lock
Thread 4
work
wait
lock
work
wait
Thread 5
work
wait
lock
work
wait
Thread 6
work
wait
lock
work
wait
Thread 7
work
wait
lock
work
wait
Thread 8
work
wait
lock
work
wait
runningreadywaitingblockedidle24 ms of wall clock
throughput
500/s
effective parallelism
2.50 / 8
lock busy
90.0%
mean lock wait
18 ms
serialised share of each task0.4 · 2.0 ms locked of 5.0 ms total — 40.0%
The critical section is busy 90% of the time. It is now the ceiling: more cores and more workers change nothing. Effective parallelism is 2.5 on 8 cores — the definition of false parallelism. Shrink the critical section or shard the lock. The critical section is 40.0% of each task, so 2.5 of 8 cores' worth of work is really happening at once. Waiting is not evenly distributed either: mean lock wait is 18 ms, and the tail is far worse than the mean because queueing delay grows non-linearly as the lock approaches saturation. Contention is not caused by threads; it is caused by the fraction of the work that must be serialised. Adding threads to a contended lock adds queue, not capacity — and past that point each extra thread makes the tail latency worse while leaving throughput exactly where it was.
SIMULATEDLanes are a discrete simulation of one mutex granted in arrival order; throughput comes from the lab model. Neither is a measurement, and real locks add cache-line traffic this omits.

What people believe, and what is true

Claim

It is only 10,000 requests — we handle more than that per second.

Reality

You handle them spread across a second. Ten thousand in one millisecond is an instantaneous concurrency of 10,000, and shared resources are sized for concurrency.

Claim

We added retries, so transient overload is handled.

Reality

Retries without jitter are the amplifier. Every timeout fired at the same instant, so the retry wave is more synchronised than the original and the system cannot drain.

Claim

A concurrency limit fixes it.

Reality

It caps what reaches the resource. The demand still exists and now sits in a queue — which, if unbounded, is the next incident with a different name.

Go deeper

Overview

One event releases a crowd, and the crowd all does the same thing to the same resource at the same time.

Practical

Jitter every TTL and every retry, single-flight expensive shared work, cap concurrency to the resource, and never schedule anything at exactly :00:00 across many instances.

Advanced

The retry loop is what converts an overload into a metastable failure: the second wave is larger and better synchronised than the first, so the system has a stable state and a collapsed state and no path between them without shedding.

Internals

At the primitive level this is notify_all versus notify_one, and a futex wake count. Waking N threads to have one acquire a lock costs N context switches and N cache-line transfers to accomplish a single acquisition — the same shape, four orders of magnitude smaller.

Apply it