Coordination & Limits

Parallelism Moves the Load Downstream

One request became one hundred parallel database queries. The endpoint got three times faster on the developer's laptop and the database fell over in production. This is the counterweight to the previous lesson: parallelising a fan-out does not reduce work, it concentrates it, and the system that absorbs the concentration is never the one you were optimising.

▶ Run the lab

The question this answers

The question

The endpoint got faster and the database got slower — where did the load actually go?

The work

An order-history endpoint that fetches 100 orders and, for each, enriches it with line items — changed from a sequential loop to Promise.all over all 100 at once, at 150 requests per second.

What is shared

The database connection pool (20 connections), the database's own worker slots, the shared buffer cache, and every other service's share of them. None of these appear anywhere in the code that was changed.

The invariant — what must stay true under every interleaving

Concurrent demand on any shared downstream resource stays below the level at which its service time degrades — the pool never has more waiters than it can drain within the request deadline, and no request holds a connection while waiting for another connection.

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?

The speedup that was never yours to take

On a laptop, against a local database with no other traffic, 100 sequential queries at 3 ms each take 300 ms and 100 parallel queries take about 15 ms. The measurement is real. What it does not include is the other 149 requests per second that will be doing the same thing in production, or the 20-connection pool they all share, or the other endpoints that need that pool too.

The arithmetic that matters is simple and nobody does it: concurrent downstream demand = request rate × fan-out width × downstream latency. At 150 req/s with a fan-out of 100 and a 3 ms query, that is 45 concurrent queries in steady state — against a pool of 20. The pool is now the bottleneck, requests queue for connections, queueing raises latency, higher latency raises concurrency further, and the system finds a new equilibrium far worse than the one it left. This is Little's law being paid attention to only after the incident (Little's Law as Working Intuition).

The curve below is the shape of that. Speedup rises while the downstream has headroom and collapses through it — and note where the peak is: not at the widest fan-out. The best configuration is a bounded one, and the code that shipped had no bound at all.

Modelled: endpoint speedup versus fan-out width, at production request rate against a 20-connection pool.SIMULATED
1 workerdashed = linear speedup100 workers · max 100.0×
The curve turns down rather than flattening, which is the signature of a saturated shared resource rather than of diminishing returns. Past the knee, added concurrency produces queueing, queueing produces timeouts, and timeouts produce retries that add still more concurrency. The knee position is a property of your pool size, query latency and request rate — the shape is general, the numbers are not.

The interleaving that turns saturation into deadlock

Saturation alone is survivable: requests get slow, some time out, and the system recovers when the burst passes. What is not survivable is the pattern where a request holds a pool connection *while waiting for another pool connection*. Then the pool cannot drain at all, because every holder is waiting on a resource that only a holder can release.

This is a textbook circular wait — the same structure as Deadlock, with the pool as the resource and the fan-out as the second acquisition. It arrives through code that looks nothing like a lock: a transaction opened for the outer query, and a Promise.all inside it for the enrichment.

The fixes are ordered by how much they help. First, never fan out while holding a connection — close the outer query before the inner ones start. Second, bound the fan-out to well under the pool size so the two can coexist. Third, replace the fan-out with one batch query, which removes the problem instead of managing it (Bounding Concurrency, batch-apis).

Fan-out issued while holding a pool connection. The pool is 4 for legibility; the shape is identical at 20.ILLUSTRATIVE
Invariant · No request holds a pool connection while waiting to acquire another one
#Request 1Request 2Request 3Request 4Connection pool (size 4)State
1acquires conn A; BEGIN; SELECT orders → 100 rows····free=3 held=1 waiting=0
2·acquires conn B; BEGIN; SELECT orders···free=2 held=2 waiting=0
3··acquires conn C; BEGIN; SELECT orders··free=1 held=3 waiting=0
4···acquires conn D; BEGIN; SELECT orders·free=0 held=4 waiting=0
5still holding A, issues Promise.all over 100 enrichment queries — each needs a connection····free=0 held=4 waiting=100
6·same: holds B, queues 100 more acquisitions···free=0 held=4 waiting=200
7····no connection can be released: every holder is blocked on an acquisitionfree=0 held=4 waiting=400
✕ Circular wait. R1 waits for a connection that only R1 (or R2/R3/R4) can free, and none of them can free one until their own wait completes. The pool is deadlocked with four connections and four hundred waiters.
8····acquisition timeouts fire at 30 s; every request fails; clients retryfree=0 held=4 waiting=800
✕ Retries double the waiter count against a pool that is still deadlocked. The database is idle — it is doing no work at all — and the service is completely down.
The database in this incident is not overloaded; it is idle. The application deadlocked itself on its own pool, and every dashboard pointing at the database says it is healthy. Close the outer statement before fanning out, bound the fan-out far below the pool size, and prefer one batch query. If a transaction must stay open, the fan-out cannot use the same pool at all.

What actually changed, measured on both sides

The reason this ships is that the pull request is a two-line diff with a benchmark attached, and the benchmark is honest about the only thing it measured. The table below is what the same change looks like when you measure both sides of the boundary — and it is the table to ask for before approving the change.

The remedies are not exotic. A concurrency bound is a few lines. A batch query is usually already supported. What both require is knowing the downstream pool size, and that number is typically owned by nobody on the team making the change. That gap is the real cause of this incident class, not the Promise.all.

SignalSequential (before)Unbounded fan-out (after)Bounded to 8 (fixed)Batch query (best)
Endpoint p50, laptop300 ms15 ms45 ms8 ms
Endpoint p99, production340 ms30,000 ms (timeout)90 ms14 ms
Concurrent DB queries at 150 req/s~45~4,500 offered, pool-capped at 20~360 offered, pool-capped at 20~2
Pool wait time p992 ms30,000 ms (acquisition timeout)35 ms0 ms
Queries per request1011011012
DB CPU35%8% — it is idle and deadlocked48%11%
Blast radiusThis endpointEvery endpoint sharing the poolThis endpointThis endpoint
What the trace blamesThe database
The same deploy, measured on the side the author looked at and the side they did not.

Key points

  • Parallelising a fan-out does not reduce work; it concentrates the same work into a shorter window, and concentration is what saturates shared resources.
  • Concurrent downstream demand equals request rate times fan-out width times downstream latency — do this arithmetic before the deploy, not during the incident.
  • The speedup curve turns *down* past the knee rather than flattening, because queueing causes timeouts and timeouts cause retries that add more load.
  • Fanning out while holding a pool connection is a circular wait: the pool deadlocks while the database sits idle, and every database dashboard says healthy.
  • The blast radius is every endpoint sharing that pool, not the endpoint that was changed.
  • A bound chosen against the downstream pool size gets most of the speedup with none of the collapse.
  • One batch query beats any amount of well-tuned concurrency, and it usually already exists.

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
  • A sequential loop offers one unit of concurrent demand downstream per in-flight request, for the duration of the whole loop.
  • A parallel fan-out offers N units per in-flight request, for a much shorter duration — the same total work, N times the peak.
  • Downstream, that peak meets a finite resource: a connection pool, a worker count, a rate limit, a thread pool.
  • While demand is below capacity, service time is flat and the speedup is close to linear.
  • Above capacity, requests queue; queue wait adds to latency; higher latency means each request holds its resources longer; concurrency rises further. The feedback loop is positive.
  • At the timeout threshold, requests fail and clients retry, adding fresh demand to an already saturated resource — the curve's downward turn.
  • If any holder of the resource waits for another unit of the same resource, the queue cannot drain at all and the pool deadlocks independently of the downstream service's health.
Interleavings that matter
  • R1 through R4 each hold a pool connection with an open transaction and each queue 100 more acquisitions; no connection can be released; the pool deadlocks with four connections held and four hundred waiters, while the database does no work.
  • At 150 req/s with fan-out 100 and 3 ms queries: 45 queries in flight in steady state against a pool of 20, so 25 wait; the wait pushes per-request latency up, which raises in-flight count, which lengthens the wait.
  • Acquisition timeouts fire at 30 s; every waiting request fails; clients retry; the waiter count doubles against the same saturated pool.
  • A second, unrelated endpoint that needs one connection per request now waits behind 400 enrichment acquisitions and times out — the endpoint that was never changed is the one that pages someone.
  • Bounded to 8: at most 8 enrichment queries per request are outstanding, so offered concurrency is ~360 rather than ~4,500; the pool stays under 90% and p99 is 90 ms.
  • Batch query: two queries per request instead of 101, concurrent demand of ~2, and the fan-out problem no longer exists to be tuned.
What it guarantees — and does not
  • Guaranteed: total downstream work is unchanged by parallelising. The same queries run, over the same rows.
  • Guaranteed: peak concurrent demand multiplies by the fan-out width.
  • Guaranteed: a bound on the fan-out caps that multiplication at the bound, regardless of input size.
  • NOT guaranteed: that the downstream resource can absorb it. Nothing in the calling code knows the pool size.
  • NOT guaranteed: that the speedup survives contact with production traffic. The laptop benchmark measured an uncontended system.
  • NOT guaranteed: that the failure appears in the changed service. It appears in whoever shares the pool, and the traces blame the database.
  • NOT guaranteed: recovery. Once retries are compounding, the system may not return to baseline without shedding load (Retry Storms: The Load You Generated Yourself).
Where contention appears
  • The connection pool is the first ceiling and usually the one that breaks; its wait time is the signal that matters and is rarely on a dashboard.
  • The database's own worker slots and buffer cache are the second: 4,500 offered concurrent queries would thrash the cache even with an unlimited pool.
  • Third-party rate limits are the third, and they fail differently — as 429s attributed to your service, sometimes with a penalty window.
  • The application's own event loop or thread pool is the fourth: N times the concurrent continuations means N times the resumption work in the same tick.
  • Every other consumer of the shared resource is contending too, which is why the blast radius is the pool, not the endpoint.
How it fails
  • Connection-pool exhaustion: acquisition waits grow past the request timeout and unrelated endpoints fail first.
  • Pool deadlock: holders waiting for more of the resource they hold, so the queue cannot drain and the database is idle throughout.
  • Retry amplification: timeouts produce retries that add demand to a saturated resource, preventing recovery (Retry Storms: The Load You Generated Yourself).
  • Rate-limit rejection from third parties, sometimes with a lockout longer than the burst that caused it.
  • Misattribution: traces and dashboards blame the database, which is healthy, so the investigation starts in the wrong place.
  • Cross-service blast radius: the endpoint that fails is not the endpoint that changed.
  • Silent regression: at low traffic the change is a pure win, so it passes staging and fails at peak.
When it helps
  • When the fan-out width is small and fixed — four calls on a product page, not one hundred derived from the input.
  • When the calls go to *different* downstream systems, so no single resource absorbs the multiplication.
  • When the downstream has verified headroom at your peak request rate, and you have the pool-utilisation graph to prove it.
  • When the fan-out is bounded to a width chosen against the downstream capacity rather than against the input length.
When it hurts
  • When the width is derived from data — a list, a page size, a search result count — because it is then unbounded by construction.
  • When every call targets the same pool, the same table or the same rate limit.
  • When the fan-out happens inside an open transaction or while otherwise holding a unit of the contended resource.
  • When the downstream is shared with other services whose owners were not part of the change.
  • When the request rate is high, because the multiplier applies at peak, which is when headroom is smallest.
How you would know
  • Before: pool utilisation and acquisition wait time at p99, at production peak. Without this baseline the after-numbers mean nothing.
  • Downstream queries per inbound request, and concurrent queries in flight — the two numbers that make the multiplication visible.
  • Pool wait time as a distribution. It is near zero until it is catastrophic; the mean tells you nothing.
  • Ratio of downstream request rate to inbound request rate. It should equal the fan-out width; anything higher is retries compounding.
  • Database-side concurrency and CPU. A saturated pool with an idle database is the deadlock signature and is unmistakable once you look for it.
  • Error rate on endpoints that share the pool but were not changed — the earliest signal that the blast radius has escaped.
  • Load-test the fan-out at production request rate, not with one request; a single request will never reproduce this (Load Test Shapes: The Shape Is the Hypothesis and load-testing).
Complexity it introduces
  • You now own a concurrency bound that must be justified against a resource owned by another team, and re-justified whenever either side changes.
  • Bounded mapping needs an implementation — a semaphore, a queue, or a library — plus a decision about what happens when the bound is reached: wait, or reject.
  • The relationship between application concurrency and downstream capacity has to be documented somewhere, or it will be rediscovered by the next incident.
  • Load tests must now model the fan-out at realistic request rates, which is a materially more expensive test than the one that existed.
Simpler alternatives
  • One batch query — WHERE id IN (...), a batch endpoint, a join. Removes the fan-out rather than tuning it, and it is almost always available (batch-apis, The Comb: N+1 as a Visible Shape).
  • Bounded concurrency with a semaphore or a mapWithConcurrency(items, K, fn) helper, with K chosen against the downstream pool (Semaphores: Counting Permits as a Resource Limit, Bounding Concurrency).
  • Keep it sequential. If the endpoint is cold or the total is acceptable, sequential is the safest thing in this lesson and costs nothing to operate.
  • Move the work off the request path: precompute, cache, or materialise the enriched view so no fan-out is needed at read time.
  • A dedicated pool for the fan-out, so saturation cannot reach the endpoints that share the main one — bulkheading, which limits the blast radius without fixing the cause.
  • Backpressure at the edge: cap in-flight requests per instance so the fan-out multiplier applies to a bounded base (Backpressure, backpressure).

One request, N downstream calls

One request, N downstream calls
Fanning out turns N × latency into 1 × latency — and one request per second into N requests per second. The second number is the one that takes the downstream service down.
latency
unbounded
sequential would be
800 ms
peak downstream concurrency
200
downstream busy
over 100%
Latency by concurrency limit
1 at a time800.0 ms · 20 rounds · peak 10 downstream
2 at a time400.0 ms · 10 rounds · peak 20 downstream
4 at a time200.1 ms · 5 rounds · peak 40 downstream
8 at a time · peak 80 concurrent against 64 slots — no steady state
16 at a time · peak 160 concurrent against 64 slots — no steady state
20 at a time · peak 200 concurrent against 64 slots — no steady state
What the downstream sees
calls per parent request20 · each parent request multiplies into 20
concurrent calls at peak200 · 64 slots exist
queued at the downstream136 · these are connections, buffers and threads it did not budget for
Wait per call: unbounded
10 parent requests × 20 concurrent calls each = 200 simultaneous calls against 64 slots. The downstream has no steady state here: latency is not high, it is unbounded, and in a real system this appears as connection-pool exhaustion, timeouts and a service that was healthy until an unrelated caller shipped a loop. The best limit at this configuration is 4 at a time (200 ms) — and note that it is usually not 20. Raising the limit removes rounds, which is a linear win; it also raises peak downstream concurrency, which becomes a cliff the moment the peak crosses what the downstream can hold. A limit costs you a little latency in the good case and is the only thing standing between a routine traffic bump and a self-inflicted outage in the bad one. Bound it, and set the bound from the downstream capacity you were actually granted — not from the fan-out you happen to have today, which will be larger next quarter.
SIMULATEDA burst of 10 simultaneous parent requests against a downstream of 64 concurrent slots; waits from the engine's M/M/c approximation. Real fan-out also pays serialisation, connection setup and a tail latency that grows with N — the fastest of N calls does not set your latency, the slowest does.

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

The producer is faster than the consumer

The producer is faster than the consumer
A permanent surplus has to go somewhere: into memory, into a blocked producer, or into the bin. The one option that does not exist is for it to go nowhere.
1/60 · t+1s
queue memory25 MB
queue depth400 · no ceiling declared
queue latency
400 ms
delivered
1,000
items lost
0
status
alive, 20s left
t+0sunbounded queue · producer 1,400/s · consumer 1,000/s
t+20squeue holds 8,000 items · 500 MB · GC pauses lengthening, latency climbing
t+21sOOM: 512 MB exhausted. Process killed. Everything still in the queue is gone, and the producer finally stops — because it died too.
400 items per second have nowhere to go, so they go into the heap: 25 MB at t+1s, and the OOM killer arrives at t+21s. Notice what this system does *not* have: it does not have "no backpressure". It has backpressure with a 512 MB buffer and a process death as its signalling mechanism. Every queue is bounded — an unbounded queue is one whose bound is the machine, whose signal is a crash, and whose overflow policy is "lose everything, including the items that were already safely queued". Whichever you pick, pick it on purpose and export the counter that proves which one fired.
SIMULATEDFixed rates over 60 model seconds, 64 KB per item, 512 MB before the process dies. Real heaps degrade before they die — GC pressure and swapping make the last few seconds far worse than this straight line suggests.

Why is 8 cores only 4.5×?

Why is 8 cores only 4.5×?
Amdahl is one term of four. Turn each effect off and watch which part of the curve straightens.
1 workerdashed = linear speedup16 workers · max 16.0×
workersidealAmdahl onlyrealisticlimited by
11.0×1.00×1.00×none
22.0×1.90×1.85×serial
44.0×3.48×3.19×serial
66.0×4.80×4.17×serial
88.0×5.93×4.17×bandwidth
1010.0×6.90×4.17×bandwidth
1212.0×7.74×4.17×bandwidth
1414.0×8.48×4.17×bandwidth
1616.0×9.14×4.17×bandwidth
speedup at 8 workers
4.17×
best point on the curve
4.17× @ 6
past best, adding workers
costs
distinct causes on curve
serial, bandwidth
At 8 workers this configuration reaches 4.17× and the dominant cause is "bandwidth". Past 6 workers the cores are fed by a memory system that is already saturated — they are stalled, not computing. More threads make the stall queue longer. The fix is fewer bytes per unit of work (better locality, smaller types), not more parallelism. The reason to name the cause is that each one has a different fix, and three of the four get worse if you respond by adding threads.
SIMULATEDcomposed from named effects, not fitted to a measurement

What people believe, and what is true

Claim

It is the same amount of work, so it cannot hurt the database.

Reality

Same work, N times the peak concurrency. Shared resources are sized for peak concurrency, not for total work, and that is exactly the quantity this change multiplies.

Claim

The database fell over, so we need a bigger database.

Reality

Check whether the database was doing anything. A saturated pool with an idle database is an application-side deadlock, and a bigger database fixes none of it.

Claim

It passed staging.

Reality

Staging has a fraction of the request rate. The multiplier is request rate times fan-out width; at one-fiftieth of the traffic the change is a pure win, which is exactly why it shipped.

Go deeper

Overview

Parallelising a fan-out concentrates the same work into a shorter window. Shared downstream resources are sized for concurrency, and that is what you just multiplied.

Practical

Compute request rate times fan-out width times downstream latency and compare it with the pool size. Bound the fan-out, never fan out while holding a connection, and prefer one batch query.

Advanced

Past saturation the feedback is positive: queueing raises latency, latency raises concurrency, timeouts produce retries. The system does not degrade gracefully and does not recover on its own — it needs shedding or a bound.

Internals

Little's law relates in-flight count, arrival rate and service time; the fan-out multiplies arrival rate at the downstream resource while its service time is fixed, so in-flight count rises linearly until the queue forms and service time itself begins to degrade.

Apply it