Worker Pools & Backpressure
How to do a lot of work without falling over: bounded concurrency, queues that say “no”, Little's Law for sizing, why latency explodes near 100% busy, backpressure in Go and Node streams, load shedding, rate limits and graceful shutdown.
The 30-second version
Sooner or later every program gets more work than it can do at once: 10,000 photos to resize, 5,000 requests per second, a million rows to sync. This page is about doing that work without falling over.
Three numbers define every pool, and most production incidents with pools come from choosing one of them badly:
How many workers?
How long a line?
What when it's full?
What you already know (and where it's taught)
This page builds on two others. If any of this is new, read those first — this page won't repeat them:
- Async / Await §11 shows why
Promise.allover 10,000 items is a bug, and how to cap concurrency with a tinymapLimitfunction in JavaScript orerrgroup.SetLimit(n)in Go. - Channels §07 builds a Go worker pool from scratch: N goroutines
rangeover one jobs channel, a feeder closes it, aWaitGroupcloses the results channel. §06 shows how unbuffered channels slow a fast stage down to a slow one — backpressure for free — and §08 shows how to stop goroutines withctx.Done().
In short, you already know how to build a pool. This page is about running one: how big to make it, why it gets slow long before it's “full”, what to do when there's more work than capacity, and how to shut it down without losing anything.
Why “start everything at once” falls over
In Go, starting a Goroutine costs a few kilobytes, so go process(photo) 10,000 times looks harmless. In JavaScript, photos.map(process) starts 10,000 promises in a millisecond. The goroutines and promises themselves are cheap. What they hold while they run is not.
Four things that run out
Let's resize 10,000 photos. Each job downloads one photo, decodes and resizes it, uploads the result and saves a row in the database. That takes about 200 ms, but only ~20 ms of it is the CPU resizing; the other ~180 ms is waiting on the network. Here's what each running job holds:
| Each running job holds… | Limited by… | What happens when you go over |
|---|---|---|
| ~20 MB of memory (a decoded photo) | The machine or Container's memory limit, say 2 GB | The process is OOM-killed — at about 100 jobs |
| 1 network Socket = 1 file descriptor | The OS's ulimit on open files, often 1,024 | EMFILE: too many open files; new connections fail |
| 1 database connection, for the save | The Connection pool: maybe 20 connections | Jobs queue inside the database driver, then time out |
| 1 request to the storage API | The API's Rate limit or concurrency limit, say 50 | HTTP 429 Too Many Requests |
Drag the slider and watch which one breaks first — and what happens to the speed:
More concurrency stops helping
Here's the step that surprises people. With 1 job at a time, each job takes ~200 ms, so you do 5 photos per second. With 10 at a time, 50 per second. With 20, 100 per second. So far, more is better, because each job spends ~90% of its time waiting on the network, and other jobs can use the CPU meanwhile. (20 jobs × 20 ms of CPU = 0.4 s of CPU per 0.2 s, i.e. about 2 cores busy — fine on most machines.)
But every job needs one of the 20 database connections. Once 20 jobs are running, the 21st can't go faster — it waits for a connection. So 1,000 jobs at once is exactly as fast as 20, and on top of that it uses 50× the memory and breaks the other three limits.
How many workers? CPU cores and Little's Law
CPU-bound jobs: about one worker per core
A CPU-bound job (resizing an image, hashing a password, parsing a big JSON file) needs the CPU the whole time. A machine with 8 cores can run exactly 8 of those at the same instant. A 9th worker doesn't add speed — it just takes turns with the others, and each switch between them (a Context switch) costs a little time and memory.
So for CPU-bound work, N ≈ number of cores:
// GOMAXPROCS(0) reads the current setting without changing it: how many
// threads may run Go code at once. It defaults to the number of cores
// (since Go 1.25, it also respects a container's CPU limit on Linux).
workers := runtime.GOMAXPROCS(0)I/O-bound jobs: Little's Law
An I/O-bound job (calling an API, querying a database) spends most of its time waiting. While it waits, it's still in flight and still holds a worker. So the question becomes: how many jobs are waiting at the same time? There's a beautifully simple law for that.
Start with the supermarket. 2 customers arrive per minute, and each spends 5 minutes in the store. How many are inside at any moment, on average? Every minute, 2 more come in, and each stays for 5 minutes, so at any moment the last 5 minutes' worth of arrivals are still inside: 2 × 5 = 10 customers.
That's Little's Law:
(λ is the Greek letter lambda, the traditional name for the arrival rate.) Here's the same idea as a picture — watch the count under the moving line:
It holds for any stable system — one where, on average, things leave as fast as they arrive, so the line isn't growing forever — whatever the pattern of arrivals: stores, servers, queues, pools. Watch the units: if λ is per second, W must be in seconds.
Worked example: sizing a pool for an API
You need to make 300 calls per second to a payment API, and each call takes 80 ms.
- L = 300/s × 0.080 s = 24 calls in flight on average.
- So you need more than 24 workers. With fewer, more calls arrive than finish, and the backlog grows forever. With exactly 24, every worker is busy 100% of the time, with no spare moment to catch up after a burst — in practice the backlog still grows (§05 shows why).
- So add Headroom: about 30 workers. Then 24 ÷ 30 = 80% of them are busy on average, which is the usual target (§05).
- Finally, check what's downstream: if the API allows only 20 concurrent calls per client, 30 workers just earns you
429errors. Your real ceiling is then 20 ÷ 0.08 s = 250 calls/s — you need a bigger quota, not more workers.
Little's Law works backwards too. A database Connection pool of 20 connections, with 5 ms queries, can do at most 20 ÷ 0.005 s = 4,000 queries/s. If a missing index makes queries take 50 ms, the ceiling drops to 400/s — same pool, 10× less capacity.
Jobs that do both
Most real jobs compute a bit and wait a bit. A classic rule of thumb from Brian Goetz's Java Concurrency in Practice:
On 8 cores, a job that waits 90 ms and computes 10 ms gives 8 × (1 + 9) = 80 workers. It's a starting point, not an answer.
The queue: bounded, unbounded, and “full”
Back at the supermarket: a line is great when ten people show up at once and the tills clear them in a few minutes. But if customers arrive faster than the tills can serve them all day, the line grows all day. Making the store bigger doesn't fix that — it just means more people are unhappy at closing time.
Three choices when the line is full
A bounded queue has a maximum length. When it's full and another job arrives, you have to pick a policy:
| Policy | What the sender sees | Supermarket | Use it when… |
|---|---|---|---|
| Block (wait) | submit() doesn't return until there's room | Hold customers at the door | The sender can slow down: a batch job, a file reader, a pipeline stage. That's Backpressure. |
| Reject | An immediate error, e.g. HTTP 503 | “We're full, come back later” | The sender is a user or another service that can retry later or go elsewhere. That's Load shedding. |
| Drop | Nothing — the job silently disappears (oldest or newest) | — | The data is replaceable: metrics samples, sensor readings, “latest position” updates. |
And the fourth option, an unbounded queue, is really “none of the above”: it never says no, so overload turns into memory growth and ever-longer waits.
Try it
One number tells you whether a pool can keep up: its load = arrivals per second ÷ capacity, where capacity = workers ÷ job time. The simulator starts with 4 workers and 400 ms jobs, so capacity is 4 ÷ 0.4 s = 10 jobs/s. The presets send 5, 9 and 13 jobs/s, which is a load of 50%, 90% and 130%. Above 100%, more arrives than can possibly be finished.
The simulator also shows p99 latency: the time that 99% of jobs beat — in other words, how slow the slowest 1% are.
Start on unbounded and press the overload preset. Then switch the policy to block and reject and compare. Also try busy · 90%: there's enough capacity on average, but watch the p99.
Things to notice:
- Well under 100% load (try 50%), the queue is mostly empty. Latency is about the job time; the queue only absorbs short random bursts. At 90% it's already a different story — §05 explains why.
- Over 100% load, the queue grows without limit — no queue size fixes that. Only more workers, faster jobs, or less work do.
- Block doesn't make the waiting disappear. It moves it to the callers (stuck in
submit()). That's exactly what you want if they can slow down, and terrible if they're users holding open connections. - Reject keeps latency low for the jobs you do accept. The price is that some get an error — on purpose, quickly, while they can still retry elsewhere.
A small pool with all three policies
Here's a reusable pool. Compared with the one in Channels §07, it takes jobs over time (instead of a fixed list), has a bounded queue, and offers both a waiting Submit and a non-waiting TrySubmit. The Go version is longer because it's production-shaped: each job runs with its submitter's context, and it is safe to shut down (§09).
// A Job must watch ctx: if ctx is already cancelled when it starts, return at once.
type Job func(ctx context.Context)
type task struct {
ctx context.Context // the submitter's context: its deadline and cancellation
fn Job
}
type Pool struct {
jobs chan task // the queue: a buffered channel, so it's bounded
wg sync.WaitGroup
ctx context.Context // cancelled if shutdown runs out of time (§09)
cancel context.CancelFunc
quit chan struct{} // closed when shutdown starts (§09)
shutOnce sync.Once // makes a second Shutdown call harmless (§09)
mu sync.RWMutex // guards closed (§09)
closed bool
busy atomic.Int64 // workers running a job right now (§10)
}
var (
ErrFull = errors.New("pool: queue full")
ErrClosed = errors.New("pool: shut down")
)
func NewPool(workers, queueSize int) *Pool {
ctx, cancel := context.WithCancel(context.Background())
p := &Pool{
jobs: make(chan task, queueSize),
quit: make(chan struct{}),
ctx: ctx,
cancel: cancel,
}
p.wg.Add(workers)
for range workers { // Go 1.22+: repeat `workers` times
go p.work()
}
return p
}
func (p *Pool) work() {
defer p.wg.Done()
for t := range p.jobs { // ends when jobs is closed AND empty
p.run(t)
}
}
// run gives the job a context that ends when EITHER its submitter gives up
// OR the whole pool is cancelled.
func (p *Pool) run(t task) {
ctx, cancel := context.WithCancel(t.ctx)
defer cancel()
stop := context.AfterFunc(p.ctx, cancel) // Go 1.21+: pool cancelled → cancel ctx
defer stop()
if p.ctx.Err() != nil { // pool already cancelled? AfterFunc calls cancel
cancel() // in its own goroutine, so do it now, before fn
}
p.busy.Add(1)
defer p.busy.Add(-1)
t.fn(ctx)
}
// Submit WAITS while the queue is full (backpressure)…
func (p *Pool) Submit(ctx context.Context, fn Job) error {
p.mu.RLock() // shutdown safety, explained in §09
defer p.mu.RUnlock()
if p.closed {
return ErrClosed
}
select {
case p.jobs <- task{ctx, fn}:
return nil
case <-ctx.Done(): // …but not forever: the caller can give up…
return ctx.Err()
case <-p.quit: // …and shutdown wakes it up too (§09)
return ErrClosed
}
}
// TrySubmit NEVER waits: full queue → error right away (load shedding).
func (p *Pool) TrySubmit(ctx context.Context, fn Job) error {
p.mu.RLock()
defer p.mu.RUnlock()
if p.closed {
return ErrClosed
}
select {
case p.jobs <- task{ctx, fn}:
return nil
default: // ← the only difference from Submit
return ErrFull
}
}Why latency explodes near 100% busy
Utilization is how busy the workers are: arrival rate ÷ capacity. One worker, 100 ms jobs (so capacity is 10 jobs/s), 8 jobs arriving per second → 80% utilization. Latency is what the job's sender experiences: time waiting in the queue plus the time a worker spends on it (the Service time).
If jobs arrived like clockwork — exactly every 125 ms — the worker would finish each one before the next arrived, and nobody would ever wait. Real traffic is random: sometimes three requests land in the same millisecond, sometimes nothing for half a second. Some jobs are quick, some are slow. Compare the two at the same average load:
Why randomness creates waiting
Picture the checkout again. A cashier who's busy 50% of the time is idle half the time, so a little bunch of customers clears quickly. A cashier who's busy 95% of the time has almost no idle time to catch up: when a bunch arrives, the line it creates takes ages to shrink, and the next bunch often arrives before it has.
The standard queueing model (random arrivals, random job times, one worker) puts a number on it:
With 100 ms jobs (the last column is for comparison: 16 workers sharing one queue, same utilization):
| Utilization | 1 − utilization | 1 worker: average (wait + work) | 1 worker: p99 | 16 workers: average |
|---|---|---|---|---|
| 50% | 0.5 | 100 ÷ 0.5 = 200 ms | ~920 ms | ~100 ms |
| 80% | 0.2 | 100 ÷ 0.2 = 500 ms | ~2.3 s | ~110 ms |
| 90% | 0.1 | 100 ÷ 0.1 = 1 s | ~4.6 s | ~140 ms |
| 95% | 0.05 | 100 ÷ 0.05 = 2 s | ~9.2 s | ~200 ms |
| 99% | 0.01 | 100 ÷ 0.01 = 10 s | ~46 s | ~700 ms |
(In this one-worker model the p99 is always about 4.6 × the average — 4.6 comes from the maths of random waits, it's ln 100.)
For one worker, going from 50% to 90% busy is less than double the traffic, but 5× the average latency. Many workers sharing a queue push the explosion much closer to 100% — but it still comes. That curve, flat and then nearly vertical, is called a hockey stick. Drag along it:
These are numbers from a model, so treat them as approximate: real traffic is usually burstier than the model assumes (making things worse), and real job times are often less random (making things a bit better). The shape is what holds everywhere.
Three lessons from the chart
- Don't plan for 100%. Run pools at roughly 70–80% busy at peak. The “idle” 20–30% isn't waste; it's what lets bursts clear quickly.
- The tail grows faster than the average. The p99 line — the slowest 1% of requests, the Tail latency — is ~4.6× the average for one worker. Users notice the slow ones, and they add up: if a page makes 100 backend calls, each one is fast with chance 0.99, so all 100 are fast with chance 0.99¹⁰⁰ ≈ 0.37. That means 63% of page loads hit at least one p99-slow call.
- More workers sharing one queue help a lot. Switch to 16 workers: at the same 80% utilization the average is barely above the job time. With many workers, it's very unlikely that all of them are busy at once. That's why one shared line beats a separate line per till — and why one pool of 16 beats four pools of 4 (unless you separate them on purpose, §10).
Backpressure end to end: semaphores and streams
Backpressure takes its name from plumbing: when a pipe narrows, pressure builds up behind it and slows the flow upstream. In software, it means a slow consumer automatically slows down whoever feeds it — instead of the work piling up in memory in between.
You've already seen the simplest version: in a Go pipeline, an unbuffered send waits until the next stage is ready, so the whole line runs at the speed of its slowest stage. This section covers the other tools.
Tool 1: a semaphore — “at most N at a time”
A Semaphore is a counter of free slots. Before starting a job you acquire a slot (waiting if none are free); when the job ends you release it. Unlike a pool, there are no long-lived workers: you start one goroutine or promise per job, but never more than N at once — and the loop that starts them pauses when all slots are taken. That pause is the backpressure.
sem := make(chan struct{}, 10) // 10 slots: a buffered channel used as a counter
var wg sync.WaitGroup
for _, row := range rows {
sem <- struct{}{} // acquire: waits here while 10 jobs are running ←
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-sem }() // release: free the slot when done
save(row)
}()
}
wg.Wait() // wait for the last onesGo also has a ready-made weighted semaphore, for when jobs have different sizes. Instead of “10 jobs”, the budget can be “512 MB of memory”, and each job takes as many units as it needs:
import "golang.org/x/sync/semaphore"
sem := semaphore.NewWeighted(512) // budget: 512 MB in total
for _, job := range jobs {
// Take job.SizeMB units (an int64). Waits until that much is free,
// or returns an error if ctx is cancelled first.
if err := sem.Acquire(ctx, job.SizeMB); err != nil {
wg.Wait() // don't abandon the jobs already running
return err
}
wg.Add(1)
go func() {
defer wg.Done()
defer sem.Release(job.SizeMB) // give the units back
process(ctx, job)
}()
}
wg.Wait()Tool 2: Node streams
A Node Stream passes data along in chunks — reading a 10 GB file never means holding 10 GB in memory. A writable stream (a file, a socket) has a small internal buffer, and its highWaterMark says how big that buffer should get: 64 KiB by default since Node 22 (16 KiB before), or 16 objects in object mode.
The whole protocol is two signals:
stream.write(chunk)returnsfalseonce the buffer has reached the highWaterMark. That means “please stop for now”.- When the buffer has emptied, the stream emits a
'drain'event: “OK, go again”.
The catch: write() returning false is only a request. If you ignore it, Node keeps accepting chunks and buffering them in memory. Try both:
Here's the same copy, three ways. The by-hand version shows the protocol; in real code, use pipeline():
import { createReadStream, createWriteStream } from "node:fs";
import { once } from "node:events";
import { finished } from "node:stream/promises";
const out = createWriteStream("copy.bin");
for await (const chunk of createReadStream("huge.bin")) { // throws on read errors
if (!out.write(chunk)) { // false = "my buffer is full"
await once(out, "drain"); // ← wait until it empties (rejects if out errors)
}
}
out.end(); // no more data
await finished(out); // wait until it's flushed to disk (or throw its error)End to end: one unbounded hop ruins it
Backpressure is a chain. A request to your service passes through several hops, and pressure only reaches the source if every hop has a bound:
| Hop | Its bound | When it's full… |
|---|---|---|
| Database | Connection pool (20) | Queries wait for a connection — with a timeout |
| Your worker pool | N workers + queue of Q | Submit waits, or TrySubmit rejects |
| HTTP handler | Max requests in flight | Returns 503 at once (§07) |
| Client | Its own timeout and retry budget | Backs off and tries later |
If any one hop is unbounded — a go func() per request, a p-limit queue with no cap, an in-memory list “just for now” — the pressure stops there. That hop quietly fills with work, its memory grows, its latency climbs, and nothing upstream finds out until it falls over.
Load shedding: saying no on purpose
Load shedding means deliberately rejecting some work when you're overloaded, so that the rest can still be served well. It sounds like giving up. It's actually how you keep serving anyone at all.
The surprising failure: busy, but useless
Clients don't wait forever. A browser, a mobile app, or another service gives up after a timeout — say 2 seconds — and shows an error or retries. Your server usually doesn't find out. So if a request waits in your queue for 3 seconds, then gets processed perfectly, nobody is there to receive the answer. That work was wasted.
That's the difference between Throughput (jobs finished per second) and Goodput (answers that reached someone still waiting). In overload, an unbounded queue keeps throughput at 100% while goodput falls toward zero. This has a name: Congestive collapse.
Watch it happen, then try the two fixes:
Look closely at the unbounded version after the spike ends at 40 s: traffic is back to normal, but goodput stays at zero. During the spike, 15 jobs/s arrived and only 10/s could be finished, so 5 extra jobs per second piled up — 150 of them, 15 seconds of work. The workers have to chew through all of them, late, before anything new is answered in time. Back at normal traffic the spare capacity is only 10 − 8 = 2 jobs/s, so the backlog takes more than a minute to clear. The outage outlasts the spike.
The three fixes, from the widget
- Bound the queue and reject when full (a 503). The cheapest and most predictable. Everything you accept is fast; everything you reject learns that immediately, while it can still retry elsewhere.
- Don't start work that can't finish in time. Before a worker starts a job, check its deadline. If the client will have given up before the job could finish, skip it.
- Both. Real systems use a bound for memory safety and a deadline check for usefulness.
Shedding at the front door
The best place to shed is as early as possible, before you've spent anything on the request — no body parsing, no database lookup. A middleware that caps requests in flight is a few lines:
// limit lets at most maxInFlight requests run at once; the rest get a fast 503.
func limit(maxInFlight int, next http.Handler) http.Handler {
slots := make(chan struct{}, maxInFlight) // a semaphore (§06)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case slots <- struct{}{}: // got a slot
defer func() { <-slots }()
next.ServeHTTP(w, r)
default: // all busy → say no, right now
w.Header().Set("Retry-After", "1") // "try again in 1 second"
http.Error(w, "overloaded, try again", http.StatusServiceUnavailable)
}
})
}
// http.ListenAndServe(":8080", limit(100, mux))The Go version uses the semaphore from §06 with a default case — the same trick as TrySubmit in §04. The JS version needs no lock around inFlight: JavaScript runs one piece of code at a time, so the check and the ++ can't be interleaved with another request.
A short wait instead of none. Rejecting the very instant all slots are busy can be too strict: a slot often frees up a few milliseconds later. A common middle ground is a queue measured in time, not length — wait up to, say, 50 ms for a slot, then shed. This replaces the select inside limit() above:
timer := time.NewTimer(50 * time.Millisecond)
defer timer.Stop()
select {
case slots <- struct{}{}: // a slot freed up within 50 ms
defer func() { <-slots }()
next.ServeHTTP(w, r)
case <-timer.C: // still full → shed
w.Header().Set("Retry-After", "1")
http.Error(w, "overloaded, try again", http.StatusServiceUnavailable)
case <-r.Context().Done(): // the client already gave up → do nothing
}Skipping work whose client is gone
The second fix lives in the worker: check the deadline before starting. In Go, the request's ctx already carries both “the client hung up” and (if you add one) a deadline, and the pool from §04 passes it through to the job. Node's req has no signal built in, so the JS version builds one:
var errTooLate = errors.New("deadline too close to start")
// In the handler:
// ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) // the client's budget
// defer cancel()
// errCh := make(chan error, 1) // the handler waits on this for the outcome
// err := pool.Submit(ctx, func(ctx context.Context) { errCh <- handleTask(ctx, task) })
// task.Expected is how long this kind of task usually takes (a time.Duration).
func handleTask(ctx context.Context, task Task) error {
// ctx.Deadline() reports the deadline, if the context has one.
if d, ok := ctx.Deadline(); ok && time.Until(d) < task.Expected {
return errTooLate // can't finish in time: don't waste a worker on it
}
return task.Run(ctx)
}429 or 503?
Both are “not now” status codes, and both can carry a Retry-After header saying how many seconds to wait:
- 429 Too Many Requests — you (this client, this API key) are over your limit. Other clients are fine. Used by rate limiters (§08).
- 503 Service Unavailable — I (the server) am overloaded or shutting down. Nothing about you in particular.
Either way, shedding only works if clients back off instead of retrying instantly. Instant retries turn every rejected request into two or three more — the opposite of shedding. Clients should retry with exponential backoff and jitter, within a retry budget (Async / Await §11 shows how).
Rate limits vs concurrency limits
At the supermarket: a rate limit is the guard letting in at most 10 customers per minute. A concurrency limit is the rule “at most 50 people inside”. On a normal day they look the same. Now the tills slow down — a card reader is broken. The guard keeps letting in 10 a minute, and the store fills up. The “50 inside” rule automatically lets fewer people in, because people are leaving more slowly.
Little's Law connects them
In flight = rate × time per job (§03). So for a dependency that normally answers in 100 ms:
| Situation | Rate limit: 50/s | Concurrency limit: 5 at once |
|---|---|---|
| Normal: 100 ms per call | 50/s × 0.1 s = 5 in flight | 5 in flight → 5 ÷ 0.1 s = 50/s |
| Slow: 2 s per call | 50/s × 2 s = 100 in flight 😱 | 5 in flight → 5 ÷ 2 s = 2.5/s |
With the rate limit, the struggling service gets 20× more open requests just when it can least afford them. With the concurrency limit, it automatically gets less traffic — exactly the relief it needs. That's backpressure again.
So when do you want a rate limit?
Rate limits answer a different question: fairness and quotas, not resource protection.
- Someone else's rules. An API you call says “100 requests per second per key”. You must stay under it, however fast they answer.
- Protecting yourself from one client. On your own API, a per-client rate limit stops one script from using up everyone's capacity. Over the limit → 429.
- Money. Each call to a paid service costs something; a rate limit is a spending cap.
In practice you often want both: a rate limit for the contract, a concurrency limit for safety.
The token bucket
The most common rate-limit algorithm is the Token bucket. A bucket holds up to B tokens and refills at r tokens per second. Each request takes one token; if the bucket is empty, the request is rejected (or waits). The refill rate is the long-run limit; the bucket size is how big a burst you allow after a quiet period.
import "golang.org/x/time/rate"
lim := rate.NewLimiter(50, 10) // refill 50 tokens/s; bucket holds 10 (max burst)
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(5) // …and never more than 5 running at once
for _, id := range ids {
// Wait for a token. Fails if ctx is cancelled first.
if err := lim.Wait(ctx); err != nil {
if gerr := g.Wait(); gerr != nil {
return gerr // a call failed (and cancelled ctx): report that
}
return err // our caller cancelled
}
g.Go(func() error { return call(ctx, id) })
}
return g.Wait()Graceful shutdown: stop, drain, cancel
Your pool will be stopped far more often than it crashes: every deploy, every scale-down, every machine replacement. A Graceful shutdown means none of those lose or corrupt work.
Here's how it usually starts. A process manager like Kubernetes, stopping your pod, sends your process the signal SIGTERM (“please stop”), waits a grace period — 30 seconds by default — and then sends SIGKILL, which ends the process instantly, mid-write if need be. Everything below has to fit in those 30 seconds.
The three steps
At the supermarket at closing time: lock the door (no new customers), serve everyone already in line, and if it's really late, ask the last few to come back tomorrow.
- Stop accepting. Close the HTTP listener and the pool's intake. New work goes to another instance.
- Drain. Workers finish the jobs that are running and the ones still queued.
- Cancel at the deadline. If draining takes too long, cancel the remaining jobs through their context or
AbortSignal, wait for workers to stop, and exit — before SIGKILL does it for you.
Step through both endings with the Shutdown method for the pool from §04:
1func (p *Pool) Shutdown(ctx context.Context) error {2 p.shutOnce.Do(func() {3 close(p.quit) // wake Submits waiting for room4 p.mu.Lock() // wait until no Submit is mid-send5 p.closed = true // later Submits return ErrClosed6 close(p.jobs) // 1. no new jobs7 p.mu.Unlock()8 })910 done := make(chan struct{})11 go func() {12 p.wg.Wait() // 2. workers drain the queue13 close(done)14 }()15 select {16 case <-done:17 return nil // 3a. everything finished18 case <-ctx.Done():19 p.cancel() // 3b. out of time: cancel20 <-done // wait for workers to exit21 return ctx.Err()22 }23}
The one trap: a late Submit
In Go, sending on a closed channel panics, and that crashes the whole program. Normally the front door closes first (srv.Shutdown waits for running HTTP handlers), so nothing calls Submit after close(p.jobs). But srv.Shutdown has a deadline too. If one slow handler is still running when it expires, it may call Submit after the pool closed its channel:
quit + closed + sync.RWMutexSubmit. Without a guard, that's a send on a closed channel — a panic that crashes the whole process mid-shutdown. With the guard, it gets ErrClosed. (Had the handler called Submit a little earlier, while the queue was full, closing quit would have woken it with the same ErrClosed instead of letting it block Shutdown.)That's why the §04 pool has a closed flag guarded by a sync.RWMutex (a read-write Lock), plus a quit channel that tells waiting Submits to give up. Here are the two halves:
func (p *Pool) Submit(ctx context.Context, fn Job) error {
p.mu.RLock() // many Submits may hold the READ lock at the same time…
defer p.mu.RUnlock()
if p.closed {
return ErrClosed // …and after shutdown they get an error, not a panic
}
select {
case p.jobs <- task{ctx, fn}:
return nil
case <-ctx.Done():
return ctx.Err()
case <-p.quit: // shutdown started while we waited for room:
return ErrClosed // give up, and let go of the read lock
}
}
func (p *Pool) Shutdown(ctx context.Context) error {
p.shutOnce.Do(func() { // only the first call closes things
close(p.quit) // 1a. wake every Submit waiting on a full queue
p.mu.Lock() // 1b. the WRITE lock waits until no Submit holds the read lock,
p.closed = true // so no send can be half-way through while we close
close(p.jobs) // 1c. no new jobs (queued ones are still handed out)
p.mu.Unlock()
})
done := make(chan struct{})
go func() {
p.wg.Wait() // 2. drain: workers finish the queue, then exit
close(done)
}()
select {
case <-done:
return nil // everything finished in time
case <-ctx.Done():
p.cancel() // 3. out of time: cancel every job's context
<-done // and wait for the workers to notice
return ctx.Err()
}
}Two promises this code relies on. First, jobs watch their ctx — otherwise <-done waits until SIGKILL. Second, a job still in the queue when p.cancel() runs is handed to a worker with an already-cancelled context (that's the if p.ctx.Err() != nil { cancel() } line in §04's run), so it returns at once. But remember Submit already returned nil when the job was queued: its caller hears about the cancellation only if the job reports its result back (like the errCh in §07). Otherwise the job just vanishes — so log it, or put it back on a durable queue (see below).
Wiring it up
func main() {
// ctx is cancelled when the process receives SIGTERM or Ctrl+C.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()
pool := NewPool(8, 100)
srv := &http.Server{Addr: ":8080", Handler: api(pool)}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
<-ctx.Done() // ← wait here until SIGTERM
log.Println("shutting down…")
// One time budget for everything, safely inside Kubernetes' 30 s.
shutCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil { // 1. front door: no new requests
log.Println("http shutdown:", err)
}
if err := pool.Shutdown(shutCtx); err != nil { // 2 + 3. drain, cancel at deadline
log.Println("pool shutdown:", err)
}
}What “cancel” should mean for a job
Cancelling stops the work, but the job still needs to end up somewhere. Pick one deliberately:
- Retry later. If jobs come from a durable queue (a database table, Kafka, SQS), don't mark the job as done; another instance picks it up. This only works if the job is Idempotent — safe to run twice — because it may have been half-done.
- Save progress. A long job (a video encode, a big export) writes a checkpoint so the next attempt resumes instead of restarting.
- Fail loudly. If neither is possible, record it as failed so a human or a retry system sees it.
An in-memory queue is lost when the process dies — gracefully or not. If losing queued jobs is unacceptable, the queue must live outside the process.
In production: bulkheads, priority, fairness, metrics
Bulkheads: separate pools for separate work
Suppose one pool of 6 workers handles both checkout requests (50 ms each) and report exports (30 s each). A user requests six reports. Now every worker is busy for 30 seconds, and checkouts wait behind them:
The fix is a Bulkhead: give each kind of work its own limit, so a flood of one kind can only exhaust its own share. It can be separate pools, separate semaphores, or separate connection pools for the same database. This is the one good reason to have several smaller pools instead of one big one (§05 showed why one big shared queue is otherwise better).
Priority, and its trap: starvation
Sometimes you'd rather keep one pool and let urgent jobs go first. The usual tool is a Priority queue, or in Go, two channels and a “check the urgent one first” loop:
for {
select { // 1. urgent work first, if there is any…
case job := <-high:
job(ctx)
continue
default: // …none waiting → fall through
}
select { // 2. otherwise take whichever comes first
case job := <-high:
job(ctx)
case job := <-low:
job(ctx)
case <-ctx.Done():
return
}
}The first select with default peeks at high without waiting. Only when it's empty do we wait on both. (A single select on both channels wouldn't work: when both are ready, Go picks one at random.)
The trap: if urgent work never stops arriving, low-priority jobs never run. That's Starvation: a job that is ready to run but never gets a turn, because something else is always chosen first. Two common fixes:
- Reserve capacity. Low-priority work may use at most 2 of 8 workers; or high-priority work always has 2 workers to itself.
- Aging. The longer a job waits, the higher its priority becomes, until it's served.
Fairness: one customer shouldn't fill the queue
On a service shared by many customers (tenants), one of them submits a 50,000-job batch. With one first-in-first-out queue, everyone else's jobs wait behind all 50,000 — Head-of-line blocking. Two fixes:
- Per-tenant limits: no tenant may have more than, say, 20 jobs queued or 5 running. Beyond that, they get 429s.
- Per-tenant queues served in turn: workers take one job from tenant A, then B, then C, then A again (round robin). A big batch just makes its own tenant's line long.
What to measure
Every pool should export a handful of numbers. Here they are, roughly in order of how early they warn you:
| Metric | Type | What it tells you |
|---|---|---|
| Queue wait time | Histogram | How long jobs wait before a worker starts them. This is what users feel, and it's directly comparable to your timeouts. The best early warning. |
| Utilization | gauge: busy ÷ N | How close you are to the steep part of the curve (§05). Sustained above ~80% → add capacity. |
| Rejections / drops | counter | Load shedding is happening. Fine in a spike; a bug or a capacity problem if constant. |
| Queue depth | gauge | How many are waiting. Useful, but ambiguous: 100 queued 1 ms jobs is nothing, 100 queued 1 s jobs is an outage. |
| Service time | histogram | How long the work itself takes. If this rises, the problem is downstream (database, API), not the pool. |
Performance engineer Brendan Gregg's USE method sums it up for any resource: Utilization (how busy it is), Saturation (how much extra work is waiting because it's busy — queue depth and wait time), Errors (rejections, failures).
// SubmitTimed records how long each job waited in the queue and how long it ran.
// `metrics` stands for your metrics library (Prometheus, OpenTelemetry, …).
func (p *Pool) SubmitTimed(ctx context.Context, fn Job) error {
enqueued := time.Now()
return p.Submit(ctx, func(ctx context.Context) {
metrics.Histogram("pool.wait_ms", time.Since(enqueued).Milliseconds())
start := time.Now()
defer func() {
metrics.Histogram("pool.service_ms", time.Since(start).Milliseconds())
}()
fn(ctx)
})
}
// ReportGauges records the gauges every 5 s until ctx is cancelled.
// Start it once, next to NewPool: go p.ReportGauges(ctx, 8) // 8 = its workers
func (p *Pool) ReportGauges(ctx context.Context, workers int) {
tick := time.NewTicker(5 * time.Second)
defer tick.Stop()
for {
select {
case <-tick.C:
metrics.Gauge("pool.queue_depth", float64(len(p.jobs))) // items waiting
metrics.Gauge("pool.utilization", float64(p.busy.Load())/float64(workers))
case <-ctx.Done():
return
}
}
}Pitfalls gallery
An unbounded queue in disguise
Most unbounded queues don't look like queues. Each of these grows without limit when work arrives faster than it's finished:
go handle(req)for every incoming message — the “queue” is the pile of goroutines.Promise.all(items.map(fn))— 10,000 promises in flight.p-limitwith no check onpendingCount— capped running, unlimited waiting.make(chan Job, 1_000_000)— technically bounded, practically a memory leak.- An array you
pushto “just until the worker catches up”.
Ask of every hop: what's the maximum, and what happens when we hit it? (§04)
A new pool for every request
A limit only works if everyone shares it. A pool of 10 created inside a request handler allows 10 per request — 100 concurrent requests means 1,000 calls at once. (app is an Express app; ids are the rows the report needs. The shared version also handles the pool saying “full”, which a shared limit will do under load.)
app.get("/report", async (req, res) => {
const pool = new Pool(10); // a fresh limit per request…
const rows = await Promise.all(
ids.map((id) => pool.run(() => load(id)))
);
res.json(rows);
});const pool = new Pool(10, 200); // one limit for the whole process
app.get("/report", async (req, res) => {
try {
const rows = await Promise.all(
ids.map((id) => pool.run(() => load(id)))
);
res.json(rows);
} catch (err) { // e.g. "pool: queue full" under load
res.status(503).json({ error: "busy, try again" });
}
});(If you run several processes, the real total is limit × processes — size it with that in mind.)
A pool bigger than what's behind it
50 workers in front of a 10-connection database pool doesn't give you 50 queries at once. It gives you 10 queries and 40 workers waiting inside the database driver — a second, hidden queue with its own timeouts and no metrics. Make the pool no bigger than the narrowest resource it uses (§02, §03), so the waiting happens in your queue, where you can measure and shed it.
A job that waits on its own pool
A job submits a sub-job to the same pool and waits for it. Once every worker is running an outer job, all of them wait for sub-jobs that need a free worker to start. Nobody finishes. With one or two orders in testing it works; under load it hangs forever.
pool := NewPool(2, 100)
for _, order := range orders { // 2+ orders → both workers
pool.Submit(ctx, func(ctx context.Context) {
done := make(chan struct{})
pool.Submit(ctx, func(ctx context.Context) {
defer close(done)
charge(ctx, order) // same pool!
})
<-done // both workers wait here; the
// sub-jobs never get a worker
})
}outer := NewPool(2, 100)
payments := NewPool(4, 100) // separate pool
for _, order := range orders {
outer.Submit(ctx, func(ctx context.Context) {
done := make(chan struct{})
err := payments.Submit(ctx, func(ctx context.Context) {
defer close(done)
charge(ctx, order)
})
if err != nil {
return // queue full, shut down or cancelled
}
select {
case <-done: // charged
case <-ctx.Done(): // we were cancelled: stop waiting
}
})
}(Errors from the outer Submit are ignored here to keep it short.) Or simplest of all: do the sub-work inline in the same worker — call charge(ctx, order) directly — without submitting it anywhere.
Holding a slot while sleeping
A retry loop that sleeps (backs off) while still holding its semaphore slot blocks a worker doing nothing. During an outage, every slot is held by a sleeping retry, and fresh work can't start.
sem <- struct{}{} // take a slot
for attempt := 0; attempt < 5; attempt++ {
if err = call(ctx); err == nil {
break
}
time.Sleep(backoff(attempt)) // sleeping… with the slot
}
<-semfor attempt := 0; attempt < 5; attempt++ {
sem <- struct{}{} // hold the slot only
err = call(ctx) // while actually working
<-sem
if err == nil {
break
}
time.Sleep(backoff(attempt)) // sleep without it
}(Real code would also stop sleeping when ctx is cancelled — see the retry helper in Async / Await §11.)
Instant retries against a shedding server
Load shedding (§07) returns a 503 so that the server gets less work. A client that retries immediately — or three times in a row — turns each rejection into more load, at exactly the wrong moment. Retries need backoff with jitter, a cap on attempts, and a Retry budget. Respect Retry-After.
CPU work in a promise pool
In Node, a pool of 8 promises running CPU-heavy code (image resizing in pure JS, big JSON parsing, crypto in a loop) still runs one at a time on the main thread, and freezes every other request meanwhile. Promise pools limit waiting work. For CPU work you need worker threads, sized to the cores (§03). Similarly, Node's built-in libuv thread pool has only 4 threads by default, so 100 concurrent fs or crypto.pbkdf2 calls queue up there — a hidden pool you can resize with the UV_THREADPOOL_SIZE environment variable.
Watching averages and queue length
An average of 80 ms can hide a p99 of 3 s, and a queue depth of 50 means nothing without knowing how long each job takes. Dashboards should show wait-time percentiles and utilization (§10). Averages are where incidents hide.
Check yourself
Predict the answer before you click. Q1 is covered in §03, Q2 in §05, Q3 in §04, Q4 in §11 (pitfall #4), Q5 in §06, and Q6 in §08.
// An API handles 400 requests per second.
// Each request takes 50 ms on average.
// On average, how many requests are in flight at once?// One worker. Jobs take 100 ms on average (random).
// 9 jobs arrive per second, at random moments.
// Roughly what's the AVERAGE time from arrival to done?jobs := make(chan Job, 2)
jobs <- a
jobs <- b // the queue is now full; no worker is reading
select {
case jobs <- c:
fmt.Println("queued")
default:
fmt.Println("rejected")
}g := new(errgroup.Group)
g.SetLimit(2) // pool of 2
for _, order := range orders {
g.Go(func() error {
sub := new(errgroup.Group) // a NEW group, with no limit
sub.Go(func() error { return charge(order) })
return sub.Wait()
})
}
g.Wait()const out = fs.createWriteStream("copy.bin");
for await (const chunk of hugeDownload) {
out.write(chunk); // return value ignored
}// A downstream service normally answers in 100 ms.
// You allow 50 requests per second to it (a rate limit).
// It slows down to 10 s per request.
// How many of your requests are in flight now?Cheat sheet
write() → false → 'drain', pipeline(). One unbounded hop breaks the chain.Retry-After at the front door. Skip jobs whose deadline is too close. Goodput, not throughput.| I want to… | Go | JavaScript |
|---|---|---|
| Cap how many run at once | errgroup.SetLimit(n), a buffered-channel semaphore, a pool (§04) | mapLimit, p-limit, a Semaphore or Pool class (§04, §06) |
| Wait when full (backpressure) | Plain send on a buffered channel, or select with ctx.Done() | await sem.acquire(); if (!w.write(c)) await once(w, "drain") |
| Reject when full (shedding) | select { case ch <- j: … default: return ErrFull } | if (queue.length >= max) throw … → 503 |
| Budget by size, not count | semaphore.NewWeighted(n) + Acquire(ctx, k) | A semaphore whose acquire(k) takes k units |
| Limit per second | rate.NewLimiter(r, burst) + Wait(ctx) / Allow() | A token bucket with lazy refill (§08) |
| Stream with backpressure | io.Copy (blocking writes) | await pipeline(src, transform, dst) |
| Shut down gracefully | signal.NotifyContext → srv.Shutdown → close jobs → wait or cancel | process.once("SIGTERM") → server.close() → allSettled → abort() |
| Use all CPU cores | runtime.GOMAXPROCS(0) workers | os.availableParallelism() worker threads |