Data & Pipeline Parallelism

Scatter/Gather and the Tail You Inherit

Query every shard in parallel and merge the answers. The result arrives at the speed of the slowest shard, which means the whole request inherits the *tail* of every shard it touched — and querying 100 shards turns a one-in-a-hundred slow response into a two-in-three one.

▶ Run the lab

The question this answers

The question

If every shard is fast 99% of the time, how often is a query across all of them fast — and what do I do about the answer?

The work

A search query broadcast to 100 index shards, each returning its top 20 matches, merged into a single ranked list of 20.

What is shared

The gather buffer holding one partial result per shard, and the merge state that produces the final ranking. The shards themselves share nothing with each other — that is the point of the partition — so all the coordination is at the gather.

The invariant — what must stay true under every interleaving

The merged result is exactly what a single sorted pass over all shards' contributions would produce, and every shard is represented exactly once — by its results, by an explicit failure, or by an explicit "omitted, deadline exceeded" marker that the caller can see.

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?

Every shard fast, the query slow

Scatter/gather is the natural read pattern for partitioned data: the query cannot be answered by one shard, so it goes to all of them, and the coordinator merges. Structurally it is Fan-Out / Fan-In: One Request Becomes N with the branches being homogeneous rather than heterogeneous — the same query, N times, over disjoint data. That homogeneity is why the tail-latency effect is so sharp: you are not waiting for one known-slow dependency, you are waiting for whichever shard happened to be unlucky this time, and with enough shards *some* shard is always unlucky.

The timeline makes it concrete. Ninety-nine shards answer in 8ms. One shard is behind a garbage collection pause, or a cold page cache, or a co-tenant's backup job, and answers in 140ms. The query took 140ms. The coordinator was idle for 132ms of it, the ninety-nine fast results sat in a buffer, and every dashboard for every shard shows a healthy p50 of 8ms.

This is the reason a service can have excellent per-node latency metrics and terrible user-facing latency, and why "which shard is slow" is the wrong question — it is a different shard every time. The right question is what the coordinator does when one shard is late, and that is a design decision you must make explicitly, because the default (wait for everyone) is the worst option available.

One slow shard out of a hundred sets the response time. Relative units.ILLUSTRATIVE
Coordinator
waiting on the last shard
Shard 1 (typical)
result buffered, unused
Shard 2..99 (typical)
query
result buffered, unused
Shard 100 (unlucky this time)
queued behind GC pause
query
↑ 99% of the work is finished here↑ the response happens here
runningreadywaitingblockedidle1 unit = 1ms

The arithmetic that surprises everyone

Treat "slower than this shard's p99" as an independent event with probability 0.01. The probability that *no* shard is slow is 0.99^N, so the probability that the whole query is slow is 1 - 0.99^N. At N=10 that is 9.6%. At N=100 it is 63.4%. Your one-in-a-hundred event has become the common case, and no individual shard did anything wrong.

Read the table the other way and it is even more useful. For the *query* to have a p99 equal to a single shard's p99, each shard would need that latency at its p99.9 (N=10) or p99.99 (N=100). Scatter/gather does not just expose the tail — it demands a tail an order of magnitude tighter than the one you are measuring, per additional order of magnitude of fan-out width. That is why large scatter/gather systems invest so heavily in tail control at the node level: pause-free allocation, request hedging, admission control, and keeping N as small as the data layout allows.

Note also the assumption doing work here: independence. Shard slowness caused by a shared cause — a network partition, a coordinated GC, a hot key routed to several replicas, a noisy neighbour on the same host — is correlated, and correlated failures make the real numbers worse than the table, not better. The table is the optimistic case.

  • p99 of the query is not p99 of a shard. It is roughly the p(99^(1/N)) of a shard, which gets extreme fast.
  • Halving N is a bigger tail improvement than most per-shard optimizations, and it is a data-layout decision.
  • If you cannot measure a shard's p99.9, you cannot reason about a 10-way gather's p99.
assume each shard exceeds its own p99 latency 1% of the time,
independently. the gather waits for the slowest.

  N shards   P(at least one slow)   per-shard percentile needed
                                    for the QUERY to hit p99
  --------   --------------------   --------------------------
      1              1.0 %                  p99
      5              4.9 %                  p99.8
     10              9.6 %                  p99.9
     20             18.2 %                  p99.95
     50             39.5 %                  p99.98
    100             63.4 %                  p99.99

read it twice:
  left  -> more shards means the tail event is no longer rare.
  right -> more shards means you must control a tail you are
           probably not even measuring.

and independence is the OPTIMISTIC assumption. correlated slowness
(shared host, shared network, coordinated GC) is worse than this.
Fan-out width versus the query tail. Modelled from independent per-shard tails.

Four ways to stop waiting for the slowest

The coordinator has exactly one lever: what it does at the moment the results are *almost* all in. Waiting is the default and the worst. The alternatives all trade something real, and choosing among them is a product decision as much as an engineering one — "is a search result that is missing one shard's contributions acceptable?" is not a question the coordinator can answer on its own.

Hedging deserves a specific note because it is the least intuitive and often the most effective: after waiting a short interval — commonly around the p95 of a shard query — send a duplicate request for the outstanding shards to a different replica and take whichever answers first. This costs a small percentage of extra load (only the requests that were already slow get duplicated) and can remove most of the tail, because the causes of tail latency are usually *local and transient* rather than properties of the query. It requires idempotent reads and cancellation of the loser, and it is strictly worse than useless if the shard is slow because it is overloaded — hedging into an overloaded replica adds load to the thing that is already failing.

The structural fix underneath all four is to reduce N. Routing a query to the one shard that can answer it — because the partition key is in the query — takes the tail arithmetic off the table entirely. Most scatter/gather in practice exists because the access pattern and the partition key disagree, which makes it a schema problem wearing a concurrency costume (Partitioning and Sharding in Database Engineering is where that conversation belongs).

  • Hedging duplicates only the requests that are already slow, so its cost is proportional to your tail, not your traffic.
  • Hedging into an overloaded replica makes things worse; gate it on a health signal and a cap.
  • A partial result must be visible in the response contract. Silently returning 97 shards' worth of data as if it were complete is a correctness bug, not a degradation.
StrategyWhat it gives upExtra loadRequiresGood when
Wait for allNothing — but inherits every shard's tailNoneNothingN is small and completeness is mandatory
Deadline + partial resultsCompleteness, visiblyNoneA response shape that can say "97 of 100 shards"Ranked or approximate results (search, analytics)
Hedged request after p95Little; some duplicated work~5% of readsIdempotent reads, replicas, real cancellationTail is transient and local (GC, cold cache)
Speculative full duplicationNothing, but doubles cost100%Replicas with spare capacityLatency matters far more than cost
Reduce N by routing on the keyQuery flexibilityNegative — far lessPartition key present in the queryAlways worth attempting first
What the coordinator can do about the slowest shard.

Key points

  • A gather completes at the speed of its slowest branch, so the query inherits the tail of every shard it touched.
  • With independent 1% tail events, P(slow query) is 1 - 0.99^N: 9.6% at 10 shards, 63.4% at 100.
  • For the query to hit p99, each shard must hit that latency at roughly p99.9 (N=10) or p99.99 (N=100) — a tail most teams do not measure.
  • The coordinator's only lever is what it does when results are almost all in: wait, deadline with partial results, hedge, or duplicate.
  • Reducing N by routing on the partition key beats every coordinator-side mitigation, and is usually a schema decision rather than a concurrency one.

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
  • The coordinator maps the query to the set of shards that could contain matches — all of them, unless the partition key is present in the query.
  • It scatters the query concurrently, each with a per-shard deadline derived from the request budget, and records which shards were asked.
  • Each shard executes independently over disjoint data and returns a partial result — commonly its own top-k, which is what makes the merge cheap.
  • The coordinator gathers into per-shard slots (no shared mutable accumulator), applies its completion policy, and merges: a k-way merge over the partials, re-ranked globally.
  • The response carries provenance: how many shards contributed, and which were omitted, so the caller can distinguish "no results" from "we did not look everywhere".
Interleavings that matter
  • Normal: 100 shards respond in arbitrary order into disjoint slots; the merge runs once, after the last one. Every ordering is equivalent because the slots do not overlap.
  • The tail case: 99 shards respond within 11ms, one responds at 140ms, and the coordinator holds the request — and its memory, its connection and its share of the pool — for 129ms of pure waiting.
  • Deadline case: the coordinator responds at 50ms with 97 shards; shard 98 responds at 140ms and writes into a gather buffer whose request has already ended. Without an explicit guard that is a write to freed or reused state (Orphaned Tasks).
  • Hedging: the coordinator sends a hedge to replica B at 20ms; A answers at 25ms, B at 30ms; the coordinator takes A and must cancel and discard B, or it double-counts shard 100 in the merge.
  • Correlated slowness: a network blip makes twelve shards slow simultaneously, the independence assumption collapses, and hedging duplicates twelve requests into a network that is already the problem.
What it guarantees — and does not
  • Guarantees a complete answer only under the wait-for-all policy, and only if no shard fails — which is why availability falls as N grows.
  • Guarantees each shard is queried at most once per attempt, and (with disjoint slots) that no shard's result overwrites another's.
  • A deadline guarantees when the coordinator responds. It does NOT guarantee the outstanding shard queries stopped, released their connections, or stopped consuming shard CPU.
  • Hedging guarantees a faster *typical* tail only if the slow cause is local and transient. It guarantees nothing when the shard is slow because it is saturated.
  • Top-k-per-shard merging guarantees a globally correct top-k only when the ranking is decomposable that way — some aggregations (exact distinct counts, global medians) are not, and per-shard partials cannot be merged into an exact answer.
Where contention appears
  • The coordinator holds one request's worth of resources for the duration of the slowest shard — at high concurrency that is a large amount of memory and connections tied up doing nothing.
  • Each shard is queried by every coordinator, so shard load scales with total query rate, not with per-coordinator rate; a shard is the shared resource for the entire fleet.
  • The merge itself is CPU work on the coordinator and becomes a bottleneck when N and k are large — a 100-way merge per query is not free.
  • Hedging adds contention exactly where the tail already is, so it must be capped (a fixed small fraction of requests) and disabled under load shedding.
How it fails
  • Tail amplification: the query p99 is far worse than any shard's p99 and no single shard looks unhealthy.
  • Availability decay: with wait-for-all, a single shard failure fails the entire query, so aggregate availability falls geometrically in N.
  • Silent incompleteness: partial results returned as if complete, so "no matches" and "we did not reach the shard holding the match" are indistinguishable to the caller.
  • Hedge storms: hedging enabled globally during a real overload multiplies load on an already-saturated tier.
  • Duplicate contributions when a hedge and its original both return and the merge does not deduplicate by shard.
  • Coordinator memory exhaustion under load, because every in-flight query holds N partial results while waiting for one straggler.
When it helps
  • Search and analytics over partitioned data, where the answer genuinely requires every partition and per-shard top-k makes the merge cheap.
  • Embarrassingly parallel read work where per-shard cost is meaningful, so the concurrency converts a large sum into a manageable maximum.
  • Systems with replicas and idempotent reads, where hedging is available as a tail-control mechanism.
  • Approximate or ranked results, where a deadline-bounded partial answer is genuinely acceptable and can be labelled as such.
When it hurts
  • When the partition key is available and the query could have gone to one shard. Then the entire tail problem is self-inflicted.
  • When N is large and completeness is mandatory — the availability and tail arithmetic are both against you and there is no coordinator-side fix.
  • When per-shard work is tiny: the scatter, the network round trips and the merge dominate, and a single larger shard would be faster.
  • When the aggregation is not decomposable, so shards cannot return small partials and the coordinator gathers enormous intermediate results.
  • When shard slowness is correlated, which invalidates both the arithmetic and the hedging strategy.
How you would know
  • Query p99 next to per-shard p99. The gap between them is the tail-amplification cost, and it is the number that justifies any of this work.
  • Straggler attribution: for each query, which shard was last, and how much of the total wait it caused. If it is a different shard every time, it is a tail problem, not a hot-shard problem.
  • Fraction of queries that hit the deadline and returned partial results — the honest measure of how often completeness is being silently traded away.
  • Hedge rate and hedge win rate: hedges sent as a fraction of requests, and how often the hedge beat the original. A low win rate means you are adding load for nothing.
  • Coordinator in-flight memory and connection occupancy, which is proportional to concurrency times N times partial size and is the usual scaling wall.
Complexity it introduces
  • The response shape must express partiality — shards queried, shards answered, shards omitted — and every consumer must handle it, or the degradation becomes a silent correctness bug.
  • Hedging adds replica selection, deduplication by shard, cancellation of the loser, a cap, and a kill switch for overload conditions.
  • Per-shard deadlines must be derived from a request budget and propagated, which requires deadline plumbing through every layer (Deadlines vs Timeouts).
  • The merge needs its own correctness argument: when top-k-per-shard composes into a global top-k, and when it does not.
  • Observability must be per-shard-per-query to attribute stragglers at all, and that is a high-cardinality telemetry problem in its own right.
Simpler alternatives
  • Route by partition key so the query touches one shard. Removes the tail arithmetic, the merge and the availability decay simultaneously — always evaluate this first.
  • Precompute the aggregate: a materialized view or a rollup answers from one place, trading freshness for a single-shard read.
  • Two-phase: query a small cheap index to identify the few shards worth asking, then scatter to those. Cuts N by an order of magnitude for many workloads.
  • Cache the query result, when repeat queries are common — a cache hit has no tail at all (Caching Architecture in Architecture).
  • Fewer, larger shards, when per-shard capacity allows. N is a variable you control, and it is in the exponent.

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 deadline expired. What happened to the work?

The deadline expired. What happened to the work?
A timeout ends your wait. On its own it does not end the request, free the worker, close the connection or roll anything back — the work carries on, invisible, and still costs exactly what it cost before.
try:
    result = await wait_for(call(req), 300ms)   # only the *wait* is bounded
except Timeout:
    return 504                                # call() is still running, on a worker, right now
Caller
awaiting response
TimeoutError returned to the user
Worker
doing the work
still running · result will be discarded
↑ deadline
runningreadywaitingblockedidle1200 ms of model time
caller waits
300 ms
worker occupied for
1200 ms
workers held by abandoned work
36.0
pool
no steady state
pool capacity at this occupancy20/s · 24 workers × 1200 ms each
offered40/s · arrivals exceed capacity
The caller stopped waiting at 300 ms and returned an error. The worker did not stop: it keeps going for another 900 ms, holding its slot, its connection and its transaction, to produce a result that will be discarded. At 40/s that is 36.0 of 24 workers permanently occupied by work nobody is waiting for. The pool has no steady state at this rate — every timed-out request makes the next one slower, which makes it more likely to time out. That loop is a retry storm even before anybody adds retries. The distinction to carry away: a timeout bounds how long you wait; only cancellation bounds how long the work runs. A system with the first and not the second degrades in the worst possible shape — the caller sees fast failures while the backend is busier than ever, and the fast failures encourage retries that add more abandoned work. Two further consequences follow: cancellation must be cooperative and therefore reaches only code that checks for it, and a timeout restarted at every hop is not a deadline — pass an absolute deadline down the call chain so the total is bounded rather than multiplied by the number of hops.
SIMULATEDPool occupancy from the engine's M/M/c model with fixed service times. Real cancellation is cooperative: it takes effect at the next cancellation point, so a worker inside a blocking syscall or a long CPU loop keeps its slot even when cancellation is on.

What people believe, and what is true

Claim

All the shards are fast, so the query is fast.

Reality

The query is as fast as the slowest shard on that attempt. With 100 shards and a 1% tail, some shard is slow on roughly two out of three queries.

Claim

p99 of the query should be about p99 of a shard.

Reality

It is closer to the p99.9 or p99.99 of a shard, depending on N. Fan-out width shifts which percentile of the component distribution you are actually exposed to.

Claim

Hedging is wasteful because it duplicates requests.

Reality

It duplicates only the requests that already passed the p95 mark, so the extra load is a few percent. The waste worth worrying about is hedging into a saturated replica.

Claim

Returning results from 97 of 100 shards is a graceful degradation.

Reality

Only if the caller can see it. Undeclared partial results make "not found" and "not looked for" indistinguishable, which is a correctness bug wearing a degradation costume.

Apply it