Start here: why not a thread per task?#
TL;DRthe 30-second version
- A thread pool is a fixed set of reusable worker threads pulling tasks from a shared queue. It exists so you neither create a thread per task (expensive) nor let the thread count grow without bound (a memory and context-switch blow-up).
- The core question is sizing: for CPU-bound work the right number is close to the core count; for I/O-bound work it is higher, because a thread that is blocked waiting on I/O holds no core and can be replaced by another that has work to do.
- The task queue in front of the pool must be bounded. An unbounded queue hides overload β it grows latency and memory until the process is OOM-killed instead of pushing back.
- When the pool is saturated and the bounded queue is full, you pick a saturation policy: reject the task, run it on the calling thread (which throttles the submitter), or block until a slot frees. This is backpressure applied to a pool.
- Giving separate workloads their own pools is isolation: one slow dependency can exhaust its own pool without freezing every other request. That is the bulkhead idea applied to threads.
The simplest way to run work in the background is to start a thread for each piece of it: a request arrives, you spawn a thread, it does the work, it exits. This reads cleanly and it works β right up until load arrives. A thread is not free. Each one reserves a chunk of address space for its stack (commonly around a megabyte) and takes a kernel scheduling slot, so creating and destroying one has real, repeated cost. Under a steady stream of short tasks you spend a large fraction of your time just making and tearing down threads instead of doing the work.
The second, worse failure is that a thread-per-task design has no ceiling. If tasks arrive faster than they finish β a traffic spike, a slow dependency, a burst of retries β the thread count climbs without limit. Ten thousand threads is gigabytes of stacks reserved and ten thousand things for the scheduler to juggle, so the machine spends its cores context-switching rather than computing. The system does not degrade gently; it thrashes and stops. (Why more threads stops helping, and the difference between concurrency and parallelism underneath all of this, is the concurrency topic β see /concurrency/sim.)
The mechanism: a queue and N workers#
A thread pool has two parts: a set of worker threads, and a task queue they share. You submit a task β a small unit of work, a function to run β and instead of executing it yourself, you put it on the queue. Each worker runs the same tiny loop forever: take the next task off the queue, run it to completion, come back for another. When the queue is empty, the workers block on it, sleeping at zero CPU cost until a task is enqueued and one of them is woken to take it.
Two things fall out of this shape for free. Because the workers are permanent, submitting a task costs an enqueue and maybe a wakeup β no thread creation on the hot path. And because there are exactly N workers, at most N tasks run at once no matter how many are submitted; the rest wait their turn in the queue. That fixed N is the pool's concurrency limit, and it is the number the whole topic is about. The queue itself is the same decoupling buffer the backpressure topic builds β a shock absorber between a fast producer and a fixed-rate consumer β so its behavior under overload (it grows, the wait grows with it) is covered there; see /backpressure/sim.
The sizing question: how many workers?#
There is no universal pool size β the right number depends on what the tasks spend their time doing. The clean split is CPU-bound versus I/O-bound work, and the two want very different pools.
- CPU-bound tasks (hashing, compression, parsing, computation) keep a core busy the whole time they run. More workers than cores does not help β the extra threads just take turns on the same cores, adding context-switch overhead for no throughput. So the target is roughly the number of cores, often core count plus one to keep a core busy during the brief moment a thread stalls on a cache miss or page fault.
- I/O-bound tasks (a database call, an HTTP request to another service, reading a file) spend most of their time blocked, holding a thread but no core. Here you want many more workers than cores, because while one worker waits on I/O its core is free for another worker that has actual computing to do. Size it to the core count and your cores sit idle waiting; size it larger and you keep them fed.
Brian Goetz put a formula on the I/O-bound case in Java Concurrency in Practice. To keep your cores at a target utilization, the number of threads is the core count, times the utilization you want, times one plus the ratio of time a task spends waiting to time it spends computing:
There is a second way to reach the number, and it is the same law the backpressure topic uses to size a queue: Little's Law. The number of tasks in flight equals the arrival rate times the time each one takes to service. So the workers you need to keep up with a steady load is roughly the request rate multiplied by the average service time. Two hundred requests a second that each take half a second of wall-clock time (mostly waiting) means about one hundred concurrent tasks in flight β so a pool of around a hundred workers, not the eight your cores suggest. This is the same L = Ξ» Γ W you can play with in /numbers/sim, pointed at threads instead of a queue.
PredictA service calls a downstream API that takes about 100 ms to reply, of which only ~5 ms is CPU on your side. You run 4 cores. A junior engineer sets the pool to 4 threads 'because 4 cores' and throughput is terrible. What size does the workload actually want, and why?
Hint: How much of each task is actually on a core, and what is the thread doing the rest of the time?
Far more than 4 β on the order of 80. This is I/O-bound work: each task spends ~95 ms blocked waiting on the downstream and only ~5 ms computing, so W/C β 19. With 4 threads, at any instant almost all of them are parked waiting on the API and your cores sit idle, so you finish maybe a few dozen requests a second when the box could do far more. Sizing to the cores is the right instinct only for CPU-bound work; for waiting work you need enough threads that some are always ready to compute while others wait. Goetz's formula gives 4 Γ 1 Γ (1 + 19) = 80, and Little's Law agrees: to keep, say, 800 req/s in flight at 0.1 s each you need ~80 concurrent tasks. The real fix, though, is often to stop dedicating a blocked thread to each wait at all β that is the event-loop model, /eventloop/sim.
When the pool is full: bound the queue, then choose#
Sizing decides how many tasks run at once. It does not decide what happens when tasks arrive faster than the pool can finish them β and that moment is where thread pools are won or lost. The work has to go somewhere, and there are only three somewheres: it waits in the queue, it is turned away, or it makes the caller wait.
The most important and most-broken decision is the queue's bound. It is tempting to use an unbounded queue so no task is ever refused. That is a trap, and it is the same trap the backpressure topic warns about: an unbounded queue does not add workers, it just lets the backlog grow longer before anything pushes back. Under sustained overload the queue climbs forever, latency climbs with it (a task waits behind everything ahead of it), memory climbs, and the process is eventually OOM-killed β a hard crash instead of a clean refusal. An unbounded queue does not prevent failure; it hides overload until the failure is catastrophic.
So the queue must be bounded, and once it is bounded you have to answer the real question: when the queue is full and every worker is busy, what happens to the next task? This is the saturation policy, and Java's ThreadPoolExecutor names the standard choices as rejection handlers:
| Policy | What it does when the queue is full | When to use it |
|---|---|---|
| Abort (reject) | Throw an error immediately; the submitter must handle it | The default. Fail fast and let the caller retry, back off, or shed β honest load shedding. |
| Caller-runs | Run the task on the submitting thread itself | Natural backpressure: the submitter is busy running the task, so it cannot submit more β it slows the producer down without dropping work. |
| Block | Make the submitter wait until a queue slot frees | Bounded producers you control; propagates the slowdown upstream, but risks stalling the caller. |
| Discard / discard-oldest | Silently drop the new task (or the oldest queued one) | Rarely β only when losing work is acceptable, e.g. superseded metrics or heartbeat updates. |
These are not exotic β they are the backpressure toolkit expressed as pool settings. Reject is load shedding at the pool's door. Caller-runs is backpressure: it makes the fast producer feel the slow consumer, exactly like a full TCP receive buffer forcing a sender to pause. Block is backpressure taken to its extreme. The one to avoid by default is silent discard, because a task that vanishes without a trace is the hardest kind of overload to debug. Which failure you prefer is the whole design choice, and the backpressure topic lays out that choose-how-to-fail decision in full β this section is that decision scoped to a thread pool.
Go deeperUnder the hood: the ThreadPoolExecutor core/max/queue quirk
Java's ThreadPoolExecutor exposes four knobs: corePoolSize (threads it keeps alive even when idle), maximumPoolSize (the hard ceiling), the workQueue, and the rejection handler. Its logic surprises almost everyone the first time: a new task creates a thread up to corePoolSize; beyond that the task is queued; only when the queue is full does the pool create threads beyond corePoolSize up to maximumPoolSize; and only when the queue is full and maximumPoolSize is reached does the rejection handler fire.
The consequence trips up production systems constantly: if you pair a large maximumPoolSize with an unbounded queue, the pool never grows past corePoolSize, because the queue never fills, so the 'max' you configured is dead code and the real behavior is 'corePoolSize threads and an infinite backlog.' If you want the pool to actually scale up under load, the queue must be bounded. Core, max, queue, and rejection are one coupled decision, not four independent ones.
Thread pool vs the single-threaded event loop#
There is a completely different way to handle many concurrent tasks that do a lot of waiting: instead of a pool of threads that each block on I/O, run one thread that never blocks and juggles thousands of in-flight operations by their readiness. That is the event loop β the model behind Redis, Node.js, and nginx β and it is worth holding next to the pool because they make opposite bets. (The event loop has its own topic and simulator, /eventloop/sim; this is only the contrast.)
| Thread pool | Single-threaded event loop | |
|---|---|---|
| How it waits | A worker blocks on each I/O call, holding a thread | Never blocks; registers interest and moves on, resumes on readiness |
| Cost of an idle wait | A parked thread (~1 MB stack) per in-flight task | A few bytes of kernel state per in-flight operation |
| Uses many cores | Yes β workers run on all cores in parallel | No β one loop is one core (run several loops for more) |
| CPU-bound work | Fine β the OS preempts a long-running worker | Dangerous β a long task freezes every other connection |
| Code style | Plain top-to-bottom blocking code per task | Async / callbacks / await β control flow is inverted |
| Examples | Tomcat/servlet pools, DB connection pools, ForkJoinPool | Redis, Node.js/libuv, nginx |
The honest summary is that they win on different axes. A thread pool lets you write simple blocking code and uses every core, but it pays a whole thread per waiting task, so massive I/O concurrency gets expensive. An event loop holds huge numbers of idle connections almost for free, but one thread means one core and one careless CPU-heavy task stalls everyone. This is why real systems combine them: an event loop at the network front for cheap concurrency, and a thread pool behind it for the blocking or CPU-heavy work the loop must not run itself. Node's own runtime does exactly this β libuv keeps a small thread pool (default four threads) to run file and DNS calls that have no non-blocking form, so the single JavaScript thread never blocks.
Thread pools in the wild
Once you recognize the shape β a bounded queue drained by a capped set of reused workers β you see it holding up most server runtimes, usually hidden behind a nicer name.
- Web servers: a servlet container like Tomcat or Jetty accepts connections onto an internal thread pool; the pool size is the real concurrency limit of the app, and tuning it (and bounding its accept queue) is standard capacity work.
- Database connection pools: HikariCP and its kin are the same idea for a scarcer resource β a small fixed set of database connections that requests borrow and return. The pool size caps concurrent queries so a spike can't open thousands of connections and topple the database.
- Language runtimes: Java's Executors factory hands out ready-made pools (fixed, cached, single-thread); Node/libuv's four-thread pool runs blocking fs and DNS calls off the event loop; Python's ThreadPoolExecutor and Go's runtime scheduler manage worker sets over goroutines.
- Isolated pools per dependency: giving each downstream call its own small pool is how Netflix's Hystrix contained failures β a hung dependency could exhaust only its own pool, not the shared one, keeping the rest of the service alive. That is the bulkhead pattern (see the bulkhead topic) applied to threads.
Go deeperUnder the hood: work-stealing and the ForkJoinPool
A plain pool has one shared queue that every worker contends on β fine at moderate scale, but the queue's lock becomes a bottleneck when many workers hammer it, and it is a poor fit for tasks that spawn sub-tasks. The work-stealing pool solves both. Each worker gets its own local double-ended queue (a deque). A worker pushes and pops its own tasks from one end with no contention; when its own deque runs dry, it steals a task from the far end of another worker's deque. Idle workers keep themselves busy by stealing, so the load balances itself without a central queue.
This is Java's ForkJoinPool, the engine under parallel streams and the default common pool. It shines on divide-and-conquer work: a task splits into sub-tasks, each worker recursively processes its own and steals when idle, and the split/steal keeps every core fed. It is a specialized tool, not the default β for ordinary submit-and-run task queues a fixed ThreadPoolExecutor is simpler and more predictable β but it is the answer when tasks fan out into more tasks and you want the cores balanced automatically.
Common mistakes & gotchas
Why not just use an unbounded queue so nothing is ever rejected?
Because an unbounded queue does not save the work β it delays the failure and makes it worse. It adds no workers, so the drain rate is unchanged; it just lets the backlog and the wait grow without limit until the process runs out of memory and crashes. A bounded queue plus a saturation policy turns that silent, catastrophic failure into a fast, honest refusal you can act on. Every queue in the system needs a bound.
Is a bigger pool always better under load?
No. Past the size the workload needs, more threads add context-switch overhead and memory pressure without adding throughput β for CPU-bound work that point is around the core count. Oversizing a pool can make a service slower and less stable, not faster. The right size comes from the work (CPU-bound β ~cores; I/O-bound β Goetz's formula or Little's Law), not from 'more is safer.'
What happens if a pool task blocks on another pool task in the same pool?
You can deadlock the pool. If every worker is busy waiting on a task that is still sitting in the queue behind them, no worker is free to run that queued task, so nothing ever completes. This is thread-pool starvation, and it is why dependent or nested tasks should not share one bounded pool β give them separate pools, or use a work-stealing pool designed for sub-tasks.
One slow dependency made my whole service unresponsive. Why?
Almost certainly a shared pool. If every request type draws from one pool and one downstream dependency hangs, its calls pile up and occupy every worker, so requests that have nothing to do with that dependency can't get a thread either. The fix is isolation: give the risky dependency its own bounded pool (the bulkhead pattern), so its failure exhausts only its own workers and the rest of the service stays alive. Pair it with a circuit breaker so you stop calling a dead dependency at all.
QuizYour image-thumbnailing service (pure CPU work, 8 cores) runs a pool of 500 threads 'to handle more load.' Under a spike, throughput drops and latency spikes. What's the most likely cause?
- The pool is too small β CPU-bound work needs one thread per request
- 500 threads for 8 cores of CPU-bound work oversubscribes the cores; the extra threads just context-switch, adding overhead without throughput
- Thread pools can't be used for CPU-bound work at all
- The queue is bounded β unbounding it would fix the latency
Show answer
500 threads for 8 cores of CPU-bound work oversubscribes the cores; the extra threads just context-switch, adding overhead without throughput β Thumbnailing is CPU-bound: each task keeps a core busy the whole time it runs. With only 8 cores, at most 8 tasks can actually make progress at once; the other ~492 threads can't run in parallel, they just take turns on the same cores, so all you've added is context-switch overhead and memory pressure. CPU-bound work wants roughly core-count threads (say 8 or 9), not hundreds. More threads only helps when threads spend time blocked (I/O-bound), because then a waiting thread frees its core for another β which is not the case here.
In an interview
When a design turns to background work, async processing, or a service under load, reach for the pool and frame it as two decisions: the size, and the saturation policy. Lead with why a pool exists at all β a thread per task is both expensive to create and unbounded under load β so you reuse a fixed set of workers draining a shared queue.
- Size from the workload, not a guess: CPU-bound work wants about the core count; I/O-bound work wants many more, because blocked threads hold no core. Name Goetz's formula (Ncpu Γ Ucpu Γ (1 + W/C)) or Little's Law (threads β rate Γ service time) to show the number has a reason.
- Bound the queue, always. Say plainly that an unbounded queue hides overload and turns a spike into an OOM crash; a bounded queue plus a rejection policy fails fast and honestly instead.
- Name the saturation policies as backpressure: reject (shed at the door), caller-runs (throttle the producer), block (push back upstream) β and avoid silent discard. Tie it to the backpressure topic, don't re-derive it.
- Reach for isolation: separate pools per dependency (the bulkhead) so one hung downstream can't starve the whole service, ideally with a circuit breaker in front.
- Contrast with the event loop when concurrency is I/O-heavy: a pool pays a thread per wait and uses all cores; an event loop holds idle waits nearly for free on one core. Strong systems use both β an event loop at the front, a thread pool behind it for blocking or CPU-heavy work.
PredictAn interviewer says: 'Your request handlers call three downstream services from one shared thread pool. One of them, the recommendations service, starts taking 10 seconds instead of 50 ms. What happens, and how would you have designed it to avoid this?'
Hint: What are all the workers doing while the slow dependency hangs, and who else needed one?
With a single shared pool, the slow recommendations calls pile up and hold workers for 10 seconds each, so within moments every worker in the pool is stuck waiting on that one dependency. Now requests that never even touch recommendations can't get a thread β the whole service goes unresponsive because one downstream got slow. The design fix is bulkhead isolation: give each dependency its own bounded pool, so recommendations calls can only exhaust the recommendations pool, and requests to the other two services keep flowing. Put a circuit breaker in front of recommendations so once it's clearly failing you stop dispatching calls to it entirely and return a fast fallback, and bound each pool's queue so the recommendations backlog sheds rather than grows without limit. The one-line answer: don't let unrelated workloads share a pool β isolate the blast radius.
References & further reading
- Java Concurrency in Practice β Ch. 8 (Applying Thread Pools) β Goetz et al.'s pool-sizing formula Nthreads = Ncpu Γ Ucpu Γ (1 + W/C) and the CPU- vs I/O-bound reasoning.
- Java ThreadPoolExecutor β API documentation β core/max/queue/keepAlive knobs and the four saturation policies (Abort, CallerRuns, Discard, DiscardOldest).
- Expedia β No Skipping the Queue (the core/max/unbounded-queue gotcha) β Why an unbounded queue means the pool never grows past corePoolSize.
- Java ForkJoinPool β API documentation β The work-stealing pool: per-worker deques and stealing, the engine under parallel streams.
- Little's Law β L = Ξ»W β the relation behind sizing a pool from request rate and service time.
- Netflix β Hystrix: thread-pool isolation (bulkhead) β Per-dependency thread pools so one failing downstream can't exhaust the whole service.