Patterns & Anti-Patterns

The Concurrency Pattern Catalogue

Thirteen shapes that recur in every concurrent system, each with the problem it solves, the structure it imposes, what it costs and how it fails. Recognising the shape is most of the work — the implementation is almost always already in your standard library.

▶ Run the lab

The question this answers

The question

What shape does this coordination problem already have a known answer for?

The work

An ingestion service that reads a file, parses records, enriches each one from three APIs, and writes batches to a database — every pattern in this catalogue appears somewhere in that sentence.

What is shared

Varies by pattern, and that is the useful axis: producer/consumer and message passing share a queue, pools share a work queue and a worker set, fork/join shares a result array with disjoint slots, and the actor model shares nothing at all.

The invariant — what must stay true under every interleaving

Every submitted unit of work is executed exactly once, its result is observed by whoever is waiting for it, and the system's resource usage stays bounded regardless of arrival rate.

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 catalogue

These are not thirteen independent ideas. They cluster: three are about handing work to a set of workers (thread pool, worker pool, producer/consumer), three about splitting one computation (fork/join, fan-out/fan-in, pipeline), three about avoiding shared state (actor, message passing, immutable snapshot), and three about limiting or gating access (read/write lock, semaphore, barrier). Single-flight is the odd one out, and it is the one most often missing from a system that needs it.

For each: the problem is the situation you are in, the structure is the arrangement of actors and state, and the failure modes are what you will actually be paged for. Deep-linked lessons carry the reasoning; this table is for recognition.

PatternProblem it solvesStructureUse whenCostFails as
Producer/consumerA fast producer and a slow consumer with no natural rendezvousProducers enqueue, consumers dequeue, a bounded queue between themRates differ, or the two stages should scale independentlyA queue to size, and a backpressure policy nobody wants to chooseUnbounded queue growing until memory dies; queue age rising while every stage looks healthy — Producer / Consumer
Thread poolThread creation costs more than the work, and unbounded threads kill the machineA fixed worker set pulling from a shared queueMany short tasks, and a ceiling on concurrency is requiredQueue latency; a blocked worker is capacity removedSaturation with all workers blocked on I/O and a growing queue — Thread Pools
Worker poolThe same, where the workers are processes or machines rather than threadsN workers consuming from a shared durable queueWork must survive a worker dying, or exceed one processSerialization, distribution and at-least-once delivery semanticsPoison message retried forever; duplicate execution on redelivery — Worker Pools Beyond Threads
Fork/joinOne computation that splits into independent parts and must be recombinedSplit, run children in parallel, wait for all, combineWork is divisible and the parts are genuinely independentSplit and join overhead; the slowest child sets the timeOverhead exceeding the work on small inputs; one slow child pinning the join — Fork/Join
Fan-out/fan-inOne request needing results from N independent servicesIssue N calls concurrently, await all, mergeThe calls do not depend on each otherN times the downstream load, per requestTail latency equal to the slowest branch; multiplied load overwhelming a downstream — Fan-Out / Fan-In: One Request Becomes N
PipelineA multi-stage transform where every item passes through every stageStages connected by bounded queues, each stage concurrent with the othersStages have different costs and can overlap in timeBuffering between stages; end-to-end latency exceeds any single stageThe slowest stage setting throughput while faster ones idle — Pipeline Parallelism: Different Items, Different Stages
ActorShared mutable state with many concurrent writersOne actor owns the state; all access is a message to its mailboxThe state has a natural owner and per-entity ordering mattersEverything becomes asynchronous; request/response needs correlationMailbox growth under load; one slow actor blocking its whole entity — The Actor Model
Message passingTwo tasks needing to coordinate without sharing memorySend owned or immutable values over a channelOwnership can be transferred rather than sharedCopy or transfer cost; no shared view of stateDeadlock on unbuffered channels when both sides send first — Message Passing
Read/write lockMany readers, occasional writer, on one structureShared read mode, exclusive write modeReads dominate and are long enough to justify the overheadMore expensive than a mutex when reads are shortWriter starvation under a steady reader stream — Read/Write Locks, Honestly
Semaphore / permitsA resource that supports N simultaneous users, not oneAcquire a permit, use the resource, release itBounding concurrency against a limited downstreamA permit leaked on an error path is capacity gone foreverPermit leak under an exception; deadlock when a holder waits for another permit — Semaphores: Counting Permits as a Resource Limit
BarrierN tasks that must all reach a point before any proceedsEach arrival blocks until the count is met, then all releasePhased computation where phase k+1 depends on all of phase kEvery phase costs the slowest participantOne participant never arriving and hanging all the others — Barriers
Single-flightN concurrent callers requesting the identical missing thingThe first caller does the work; the rest await its in-flight resultA cache miss or lazy initialization under concurrent demandShared fate — everybody gets the one caller's errorWithout it: a thundering herd of identical work on every miss — Single-Flight Coalescing
Immutable snapshotReaders needing a stable view while a writer updatesPublish a new complete version; readers hold the one they gotReads vastly outnumber writesA full copy per write; old versions retained while referencedVersion retention growing with the slowest reader — Copy-on-Write as a Concurrency Strategy
Problem, structure, when it helps, what it costs, how it fails.

They compose, and the composition is where systems are designed

The ingestion service in the opening line is not one pattern; it is five, arranged. A pipeline of stages, each stage a thread pool consuming from a bounded queue, the enrichment stage doing a fan-out to three APIs behind a semaphore that bounds total in-flight calls, single-flight collapsing duplicate lookups for the same key, and the batch writer using an immutable snapshot of the mapping rules.

What composition buys is that each pattern handles one concern with a known failure mode. What it costs is that the failure modes interact: the semaphore limits enrichment concurrency, which slows that stage, which fills the queue behind it, which triggers backpressure on the parser, which is exactly the behaviour you wanted — but only if every queue in the chain is bounded. One unbounded queue anywhere converts the whole chain's backpressure into memory growth at that point.

The design rule that falls out: every boundary between stages needs an explicit answer to "what happens when the downstream is slower than the upstream". Block, drop, or reject. Those are the three, there is no fourth, and "grow" is not one of them because it is only "die later". See Backpressure.

Five patterns in one ingestion path
blocks when full — backpressurecollapse duplicate keysacquire before any callat most 64 in flight, 3 per recordone version per batchFile reader (producer)Mapping rules (immutable snapshot)Bounded queue (1k)Parse stage — thread poolBounded queue (1k)Enrich stage — thread poolSingle-flight by keyBatch writerSemaphore (64 permits)DatabaseThree APIs (fan-out/fan-in)
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

Choosing: three questions in order

Most pattern selection collapses to three questions asked in this order. First: is there shared mutable state, and can it be removed? If it can — by immutability, by ownership transfer, or by giving it a single owner — the pattern you want is in the "avoid sharing" cluster and you can stop reading. This is the same move as Immutability as a Concurrency Strategy, applied at the architectural level.

Second: is the work divisible, or is it a stream? Divisible work with a join point is fork/join or fan-out/fan-in. A continuous stream is producer/consumer or a pipeline. Getting this wrong produces the two most common structural mistakes in the catalogue: a pipeline built where a fork/join was needed, so results arrive out of order and nobody notices; and a fork/join built where a stream was needed, so the system batches when it should flow.

Third: what is the ceiling, and who enforces it? Every pattern here needs a bound — pool size, queue capacity, permit count, fan-out width. A pattern chosen without its bound is an anti-pattern with a nice name, which is precisely the argument of Unbounded Concurrency and Bounding Concurrency.

The right patterns with no bounds — this is not a design
1// "Pipeline" with unbounded stages and unbounded fan-out.
2const parsed = records.map(parse) // all in memory
3const enriched = await Promise.all( // 50,000 concurrent
4 parsed.map(async (r) => ({
5 ...r,
6 ...(await Promise.all([apiA(r), apiB(r), apiC(r)])), // x3 = 150,000
7 })),
8)
9await db.insertAll(enriched)
The same patterns with every bound stated
1const sem = new Semaphore(64) // total in-flight API calls
2const inflight = new Map<string, Promise<Enrich>>() // single-flight by key
3
4async function enrich(r: Record): Promise<Enriched> {
5 const key = r.customerId
6 let p = inflight.get(key)
7 if (!p) {
8 p = sem.run(() => Promise.all([apiA(r), apiB(r), apiC(r)]))
9 .finally(() => inflight.delete(key))
10 inflight.set(key, p)
11 }
12 return { ...r, ...(await p) }
13}
14
15// Bounded stage: at most 32 records in flight, queue applies backpressure.
16for await (const batch of chunked(pool(parseStream, 32), 500)) {
17 await db.insertAll(await Promise.all(batch.map(enrich)))
18}

Both versions use fan-out/fan-in, a pipeline and a pool. Only the second has a number attached to each one. The bound is not an optimization added later — it is the part of the pattern that makes it a pattern rather than a hope.

Key points

  • Thirteen recurring shapes, in four clusters: hand work to workers, split one computation, avoid sharing, and gate access.
  • Recognising the shape is most of the work; the implementation is nearly always already in the standard library.
  • Real systems compose several patterns, and the composition works only if every boundary between them is bounded.
  • Choose in order: can the shared state be removed, is the work divisible or streaming, and what enforces the ceiling.
  • A pattern named without its bound — pool size, queue capacity, permit count, fan-out width — is an anti-pattern with better branding.

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
  • Identify the shared mutable state, if any, and check whether ownership transfer or immutability removes it.
  • Classify the work: one divisible computation with a join, or a continuous stream of independent items.
  • Pick the cluster the classification implies, then the specific pattern by whether the stages have different costs and whether ordering matters.
  • Attach a bound to every dimension the pattern exposes, and decide the policy when that bound is reached: block, drop or reject.
  • Compose by connecting patterns through bounded boundaries, so that pressure propagates upstream instead of accumulating in memory.
Interleavings that matter
  • Producer/consumer, bounded: P enqueues until the queue is full; P blocks; C dequeues one; P proceeds — the block is the backpressure signal, and it is the design working.
  • Producer/consumer, unbounded: P enqueues 4 million items while C processes 900 per second; nothing blocks, nothing errors, and the process dies of memory forty minutes later.
  • Fork/join with one slow child: children 1-7 finish in 10 ms, child 8 takes 900 ms, the join returns at 900 ms — parallelism gained nothing because the span, not the work, set the time.
  • Fan-out under a semaphore: 200 records arrive, each wanting 3 calls; 64 permits are held, record 22 blocks on acquire until a permit is released — downstream never sees more than 64 concurrent calls regardless of arrival rate.
  • Single-flight: 300 requests miss the cache for key K within 5 ms; the first issues the load, 299 attach to the in-flight promise; one backend call is made and 300 responses are served — and if that call fails, all 300 fail together.
  • Actor: two writers send "increment" to the same actor; the mailbox orders them; both are applied — no lock anywhere, and per-entity ordering is structural rather than enforced.
  • Barrier with a lost participant: 7 of 8 workers arrive at the barrier and block; the eighth threw an exception and exited without arriving; all 7 wait forever.
What it guarantees — and does not
  • A bounded queue guarantees memory is bounded and that a full queue is observable; it does NOT guarantee the producer handles the block sensibly.
  • A thread pool guarantees at most N tasks run simultaneously; it does NOT guarantee any task starts within a bounded time, or that a blocked worker will ever come back.
  • Fork/join guarantees all children complete before the join returns; it does NOT guarantee any of them ran in parallel, or that the split was worth it.
  • A semaphore guarantees at most N permits are outstanding; it does NOT guarantee a leaked permit is ever returned, and a leak is permanent capacity loss.
  • An actor guarantees serial processing of its own mailbox; it does NOT guarantee ordering between different actors, or that the mailbox is bounded.
  • Single-flight guarantees one execution per key per window; it does NOT isolate callers from each other's failures — shared work means shared fate.
  • A barrier guarantees all participants have arrived before any proceeds; it does NOT notice a participant that died before arriving.
Where contention appears
  • Queue-based patterns contend on the queue head and tail; at very high rates that becomes the bottleneck and argues for per-worker queues with stealing. See Work Stealing.
  • Pool patterns contend for workers: the queue wait time is the visible cost, and a blocked worker is capacity that has silently left the pool.
  • Gate patterns (semaphore, barrier, read/write lock) contend by design — that is their function — and the cost is measured as wait time on acquire.
  • Sharing-avoidance patterns move contention to the mailbox, the channel or the allocator rather than eliminating it.
  • Composition concentrates contention at the slowest stage, which is where every queue in the chain will be full and every earlier stage idle.
How it fails
  • Unbounded queue growth, presenting as memory exhaustion or as queue age rising while throughput looks normal.
  • Pool saturation: every worker blocked on I/O, the queue growing, and CPU near zero — the signature that says the pool is not the bottleneck the pool metrics suggest.
  • Tail-latency amplification in fan-out: the request takes as long as the slowest of N branches, so p99 per branch becomes near-certain per request.
  • Downstream overload from multiplied load, where N-way fan-out per request turns a 2x traffic increase into a 6x increase somewhere else.
  • Permit or worker leak on an exception path, removing capacity permanently and silently.
  • Deadlock between patterns: a pool worker submitting to the same pool and waiting for the result, with no worker left to run it.
  • Shared-fate failure under single-flight, where one bad load fails every attached caller at once.
  • Silent reordering when a pipeline is used where ordering mattered and nothing enforces it.
When it helps
  • When the problem is recognisably one of these shapes, which it usually is — reaching for the named pattern gets you its known failure modes and its known bounds for free.
  • When a team needs shared vocabulary: "this is a fan-out under a semaphore with single-flight" communicates a design in one sentence.
  • When composing stages with different costs, because bounded boundaries make the pressure visible and localise the bottleneck.
  • When reviewing: the catalogue turns "does this look right" into "which bound is missing".
When it hurts
  • When the work is not actually concurrent — a pattern applied to a sequential problem adds queues, workers and failure modes to buy nothing. See Concurrency Is Always Bought With Complexity.
  • When patterns are stacked without measurement, producing a pipeline of six stages where one stage was 95% of the time.
  • When the pattern name is adopted without its bound, which is the most common way a catalogue does damage.
  • When ordering requirements are real and the chosen pattern does not preserve order, which is discovered downstream and much later.
How you would know
  • Queue depth and queue age at every boundary; age is the one that reveals a stalled consumer while depth still looks acceptable.
  • Pool utilization split into running versus blocked workers — the split is the diagnosis, the total is not.
  • Per-branch latency distribution in any fan-out, so the branch setting your p99 is identifiable rather than inferred.
  • Permit acquisition wait time and outstanding permit count, which together detect leaks and mis-sized limits.
  • Single-flight coalescing ratio: callers served per underlying execution. A ratio near 1 means the pattern is doing nothing.
  • End-to-end latency against the sum of per-stage latencies in a pipeline; a large gap is queueing, not processing.
Complexity it introduces
  • Every pattern adds a bound to choose, a policy for exceeding it, and a metric to watch — three decisions each, multiplied by composition.
  • Composed patterns interact, so debugging requires understanding the whole chain rather than one stage.
  • Asynchrony spreads: introducing a queue converts a call stack into a correlation problem, and stack traces stop telling the whole story.
  • Error propagation must be designed per boundary — a failure in stage three has to reach whoever cares, and queues break the default path.
  • Cancellation must be designed per boundary too, or a cancelled request leaves work queued in three places. See Cancellation Propagation.
Simpler alternatives
  • Do it sequentially and measure. A single-threaded loop is the correct answer far more often than a catalogue tempts you to believe. See Concurrency Is Always Bought With Complexity.
  • Use the runtime's built-in structure — a stream with backpressure, a bounded channel, a task group — instead of assembling one from primitives.
  • Push the coordination into infrastructure: a real queue broker gives you durability, retries and dead-lettering that an in-process queue does not.
  • Remove the shared state so that most of the catalogue becomes unnecessary. See Immutability as a Concurrency Strategy and Message Passing.

Concurrency lab

Concurrency lab
Six knobs, one model. Ask it the only question that matters: does more concurrency help this workload, and what stops it?
SIMULATEDThese numbers describe no real system.

They come from a queueing and contention model inside Engineer Atlas. What is faithful is the behaviour: work that waits benefits from more workers, work that computes does not, a wide critical section pins parallelism near 1 no matter how many cores you buy, and arrivals past capacity produce an unbounded queue rather than a large latency. Real arrivals are burstier than this model assumes, so real systems reach every one of these walls earlier than the sliders suggest. Do not quote a millisecond from this page.

Controls
Cores the process may actually run on. This is the parallelism ceiling.
Threads or tasks in flight. Not the same quantity as cores, and rarely the same number.
Time actually holding a core. This is the only part cores can parallelise.
Waiting while holding no core. This is the part concurrency can hide.
The slice of the CPU work only one task may execute at a time. Clamped to the CPU time.
Offered load. Past capacity the queue has no steady state at all.
Snapshot the current settings, then change one thing. The model is pure, so the “before” column costs nothing to keep.
throughput
150/s
offered 150/s
latency
31 ms
service 30 ms
effective parallelism
2.67
of 4 cores
lock wait
0.0 ms
no critical section
core wait
0.5 ms
queued for a core
switch overhead
0.3 ms
5 switches/task
CPU utilisation38%
Lock utilisation (no critical section)0%
healthy
Retiring 150/s at 38% CPU. Headroom remains; the next constraint appears at about 267/s.
Change one thing · each preset snapshots the current settings first
healthystatus comes from the model’s discriminated result, not from reading the sentence belowSIMULATED

Producers, a bounded queue, consumers

Producers, a bounded queue, consumers
The queue is the only thing they share, and its capacity is the only thing standing between a mismatched pair of rates and unbounded memory. Watch who ends up waiting on whom.
1/40 · tick 1
queue depth0 · 0 of 4 slots used
Producer 1
blocked on put()
Producer 2
blocked on put()
Consumer 1
consuming
consuming
consuming
consuming
consuming
consuming
consuming
consuming
consuming
runningreadywaitingblockedidle40 ticks × 10 ms
offered rate
100/s
consumer capacity
33/s
consumers busy
over 100%
wait for a consumer
unbounded
At tick 1, 1 consumer is parked inside take() with an empty queue — waiting on a producer, holding a thread and doing nothing. Structurally, 2 producers offer 100/s against a consumer capacity of 33/s. The queue cannot absorb a permanent surplus, only a temporary one — so the bound does its job by blocking producers, which is exactly the point: the capacity converts an unbounded memory problem into a bounded latency problem, and pushes the imbalance back up the pipeline where somebody can see it. Two failure modes hide in this diagram and neither is a deadlock: a blocked producer is backpressure working, and an idle consumer is capacity you paid for and did not use. The queue does not create throughput — the slower side always sets it. What the queue buys is tolerance for jitter, and what it costs is latency (an item sits in it) and memory (it holds items), which is why the capacity is a design decision and not a default.
SIMULATEDTicks are 10 ms of model time with fixed service times; the steady-state wait comes from the M/M/c approximation in the engine. Real arrivals are bursty and real service times vary, so real queues form earlier and deeper than this.

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.

Pipeline visualizer

Pipeline: Read → Parse → Process → Write
Several items in flight at once. Change a stage duration and watch which one actually sets the rate.
Read 4ms
#1
#2
#3
#4
#5
#6
Parse 3ms
Process 8ms ◀
#1
#2
#3
#4
#5
#6
Write 2ms
↑ first item out at 17 ms
runningreadywaitingblockedidlems
first item latency
17 ms
mean item latency
27 ms
throughput
105/s
steady-state rate
125/s
Sequential: 6 × 17 ms102 ms
Pipelined makespan57 ms
Every item still takes at least 17 ms end to end — pipelining made no single item faster, and the first one still comes out at 17 ms. What rose is the rate: 6 items finish in 57 ms instead of 102 ms, a 1.79× improvement in throughput at unchanged per-item cost.
The rate ceiling is 1 / the slowest stage — Process at 8 ms gives 125/s, and no amount of speeding up the other three moves it. Try it: shrink Write and watch the throughput refuse to change, then shrink Process by 1 ms and watch it move. Note also that mean latency (27 ms) has drifted above the 17 ms floor: items are queueing in front of the slow stage, which is where an unbounded buffer would start growing.
bottleneck: Process at 8 msSIMULATED

What people believe, and what is true

Claim

Patterns are interchangeable — pick whichever is familiar.

Reality

They differ in what they guarantee about ordering, bounding and failure propagation. A pipeline where a fork/join was needed silently reorders results; a fork/join where a stream was needed batches work that should have flowed.

Claim

Using a well-known pattern makes the code correct.

Reality

It makes the failure modes known, which is not the same thing. Every pattern here fails, and most of them fail because a bound was omitted or a permit leaked on an error path.

Claim

More patterns means a better design.

Reality

Each one adds a queue, a bound, a metric and a failure mode. A pipeline of six stages where one stage is 95% of the cost is strictly worse than one stage and a measurement.

Go deeper

Overview

Most concurrency problems are one of about thirteen recognisable shapes. Identify the shape, use the library implementation, and attach its bound.

Practical

Ask in order: can the shared state be removed, is the work divisible or streaming, and what enforces the ceiling. Then choose, and write the bound down as a configured number.

Advanced

Design compositions so pressure propagates upstream: every boundary bounded, every bound with a stated policy on block/drop/reject, and cancellation and error propagation designed per boundary rather than inherited.

Internals

The clusters map onto what they do with memory. Queue patterns share a data structure and contend on its endpoints. Split patterns share a result region with disjoint writes and contend only at the join. Sharing-avoidance patterns move the cost to copying and allocation. Gate patterns contend on a single counter, which is one cache line and therefore a scaling limit of its own.

Apply it