Varnish uses a thread-per-request concurrency model with a bounded pool. Every client request occupies a worker thread for its entire lifecycle, from accept through response delivery. When the pool is full and the overflow queue overflows, sessions are dropped.
The three parameters that control this behavior, thread_pools, thread_pool_min, and thread_pool_max, determine how Varnish responds to load spikes, slow backends, and idle periods. The dominant factor in thread pool sizing is backend response time, not request rate. A cache miss that holds a thread for 2 seconds consumes the same pool capacity as thousands of sub-millisecond cache hits. Slow backends are the single most common cause of thread pool exhaustion, and they make thread_pool_max the parameter that matters most under stress.
This guide covers how the three parameters interact, how to recognize misconfiguration from runtime metrics, and how to size pools against your actual backend latency profile rather than your peak request rate.
How the thread pool works
Varnish’s child process maintains one or more thread pools. Each pool is an independent set of worker threads with its own lock. When a client connection arrives, the acceptor hands it to an idle worker from one of the pools. If no worker is available, Varnish either creates a new thread (if below the per-pool maximum) or queues the request. If the queue overflows, the session is dropped.
CPU can be idle, memory can be plentiful, and Varnish will still refuse connections if all worker threads are blocked waiting on slow backend responses. This is the most common Varnish outage pattern: thread pool exhaustion with idle resources.
The pool grows by creating new threads, but the rate of creation is governed by thread_pool_add_delay. In Varnish 6.0+, the default is 0 seconds, meaning threads are created as fast as needed. In older versions, the default was 2 milliseconds between thread creations, which meant a sudden traffic spike could cause temporary session drops while the pool ramped up.
Threads above thread_pool_min that sit idle for longer than thread_pool_timeout (default 300 seconds) are destroyed. If idle traffic oscillates around thread_pool_min, you will see constant create/destroy cycling in the thread counters, which wastes CPU and indicates the minimum is set too low for your traffic floor.
flowchart TD
A[Connection arrives] --> B{Idle worker?}
B -->|yes| C[Process request]
B -->|no| D{Below pool max?}
D -->|yes| E[Create thread]
E -->|gated by add_delay| C
D -->|no| F{Queue space?}
F -->|yes| G[Queue request]
G --> C
F -->|no| H[Drop session]
C --> I{Idle above min?}
I -->|timeout| J[Destroy thread]
I -->|active| K[Keep in pool]The three core parameters
| Parameter | Default | What it controls | Flags |
|---|---|---|---|
thread_pools | 2 | Number of independent thread pools | Delayed; decreases need restart |
thread_pool_min | 100 | Minimum idle threads kept alive per pool | Delayed |
thread_pool_max | 5000 | Maximum threads per pool (hard ceiling) | Delayed |
thread_pools: pool count and lock contention
Each thread pool has its own mutex. More pools means less lock contention on multi-core systems. The default of 2 is adequate for most deployments. Do not exceed one pool per CPU core; beyond that, lock overhead outweighs the parallelism benefit.
Increasing thread_pools can be done at runtime via varnishadm param.set. Decreasing it requires a restart to take effect.
Total worker threads across the entire Varnish process equals thread_pools multiplied by thread_pool_max. With defaults (2 pools, 5000 max each), the theoretical ceiling is 10,000 threads.
thread_pool_min: the idle floor
This is the minimum number of threads each pool always maintains. When traffic drops, threads above this count are destroyed after thread_pool_timeout seconds of idleness. When traffic picks up again, threads are created back up to demand, rate-limited by thread_pool_add_delay.
If your idle traffic baseline requires 300 concurrent threads, setting thread_pool_min to 100 means Varnish will destroy 200 threads during quiet periods, then recreate them when traffic returns. The result is visible as churn in MAIN.threads_created and MAIN.threads_destroyed. Set thread_pool_min high enough that idle traffic does not cause the pool to shrink below what the next traffic pulse will immediately need.
thread_pool_max: the ceiling
This is the hard limit on threads per pool. When the pool hits this ceiling and all threads are busy, new requests queue. When the queue overflows, sessions are dropped.
The default of 5000 per pool is adequate for most workloads with fast backends and good cache hit rates. With slow backends, each thread is held longer, reducing effective concurrency. A backend with 500ms P99 TTFB that serves 10,000 cache misses per second needs at least 5,000 threads just for backend fetches, assuming misses are evenly distributed across time.
Each thread consumes stack memory. The default thread_pool_stack is 64KB. At the default ceiling: 2 pools times 5000 threads times 64KB equals approximately 640MB just for thread stacks. This is real memory that cannot be used for cache storage. Setting thread_pool_max higher than necessary wastes RAM without improving throughput.
When thread_pool_max is the active bottleneck, MAIN.threads_limited increments. This counter is the definitive signal that the ceiling is too low for the current workload.
Related parameters that affect pool behavior
Several additional parameters interact with the three core knobs:
thread_pool_add_delay: Controls how fast threads ramp up under load. Default is 0 seconds in Varnish 6.0+. Setting this too high (even 10ms) causes slow ramp-up under spikes, leading to temporary session drops during warmup after a restart or sudden load increase.thread_pool_timeout: How long excess idle threads survive before destruction. Default is 300 seconds. Lower values make the pool shrink faster, increasing create/destroy churn. Higher values keep threads alive longer, reducing ramp-up latency but holding memory longer.thread_pool_stack: Per-thread stack size. Default is 64KB. Total stack memory equalsthread_pools * thread_pool_max * thread_pool_stack. This interacts directly withthread_pool_maxfor memory budgeting.thread_queue_limit: Maximum queued requests per pool before drops. Default is 20. This is the last buffer beforeMAIN.sess_droppedorMAIN.req_droppedstarts incrementing.thread_pool_reserve: Introduced in Varnish 6.4.0. Reserves threads for vital internal tasks to prevent lower-priority work from starving critical operations. Default is 0, which auto-tunes to 5% ofthread_pool_min. Not available in Varnish 6.0 LTS.
Sizing against backend TTFB, not request rate
The most common tuning mistake is sizing thread_pool_max against peak request rate without accounting for how long each request holds a thread. The number that matters is concurrent in-flight requests, not requests per second.
For cache hits, a thread is held for microseconds. For cache misses, a thread is held for the full backend fetch duration: time to first byte plus body transfer time. If your backend P99 TTFB is 1 second and your cache miss rate produces 2,000 concurrent misses at peak, you need at least 2,000 threads just for miss traffic. Cache hit traffic adds negligible thread demand by comparison.
This is why slow backends cause thread pool exhaustion while CPU sits idle. The threads are not doing work; they are blocked waiting on backend responses. Increasing thread_pool_max without addressing backend performance just delays the cliff.
To measure backend TTFB:
# Backend time-to-first-byte for recent fetches
varnishlog -g request -i Timestamp -q 'BerespStatus gt 0'
# Look for Bereq (request sent) and Beresp (response header received) timestamps
Varnish does not expose latency as varnishstat counters. You must use varnishlog or varnishncsa for timing data.
Diagnosing pool exhaustion from metrics
| Symptom | Likely cause | Counter to check |
|---|---|---|
MAIN.threads at thread_pools * thread_pool_max | Pool at capacity, backend latency likely | MAIN.threads, MAIN.threads_limited |
Constant threads_created / threads_destroyed churn | thread_pool_min too low for idle traffic | MAIN.threads_created, MAIN.threads_destroyed |
| Slow ramp after restart or traffic spike | thread_pool_add_delay too conservative | MAIN.threads during warmup window |
thread_queue_len sustained above zero | thread_pool_max too low or backends too slow | MAIN.thread_queue_len, MAIN.threads_limited |
sess_dropped or req_dropped incrementing | Pool exhausted, queue overflow | MAIN.sess_dropped, MAIN.req_dropped |
threads_failed above zero | OS refusing thread creation (ulimit, memory) | MAIN.threads_failed |
| Threads at max with idle CPU | Backend latency holding threads | Backend TTFB via varnishlog |
The key diagnostic command for pool state:
# Check thread pool state at a glance
varnishstat -1 -f MAIN.threads -f MAIN.thread_queue_len \
-f MAIN.threads_limited -f MAIN.threads_failed -f MAIN.pools
If MAIN.threads equals thread_pools times thread_pool_max and MAIN.threads_limited is incrementing, the pool is at capacity. If MAIN.thread_queue_len is also nonzero, drops are imminent or already happening. Check MAIN.threads_failed separately: a nonzero value means the OS is blocking thread creation (ulimits, cgroup memory limits), which is a system-level problem rather than a Varnish tuning problem.
For alerting, monitor these counters at per-second resolution:
| Counter | Alert condition |
|---|---|
MAIN.thread_queue_len | Sustained nonzero for >10 seconds |
MAIN.threads_limited | Any nonzero rate |
MAIN.threads_failed | Any nonzero value |
MAIN.sess_dropped / MAIN.req_dropped | Any sustained nonzero rate |
MAIN.threads_created / MAIN.threads_destroyed | Constant nonzero rate when traffic is stable |
Runtime tuning: what you can change live
Most thread pool parameters can be changed at runtime without restarting the child process:
# Show current values
varnishadm param.show thread_pool_max
varnishadm param.show thread_pool_min
varnishadm param.show thread_pools
# Increase the per-pool ceiling live
varnishadm param.set thread_pool_max 8000
# Raise the idle floor to reduce create/destroy churn
varnishadm param.set thread_pool_min 200
The exception is thread_pools: increasing it works at runtime, but decreasing requires a restart. All changes made via varnishadm param.set are ephemeral and will be lost on restart. Persist them in your startup configuration using -p flags or your systemd unit.
Changes to thread_pool_max take effect immediately for new thread creation. Existing threads are not affected.
How Netdata helps
Netdata collects MAIN.threads, MAIN.thread_queue_len, MAIN.threads_limited, and MAIN.threads_failed per second. This resolution matters for thread pool saturation, which can spike and recover inside a 15-second scrape interval and produce no visible trace in coarser monitoring.
Correlating thread count with MAIN.sess_dropped and MAIN.req_dropped on the same timeline shows exactly when pool exhaustion translates to user-visible drops. Tracking threads_created and threads_destroyed rates reveals create/destroy cycling caused by an undersized thread_pool_min, which is hard to spot from cumulative counters alone.
Anomaly detection on thread_queue_len flags the transition from brief queueing to sustained saturation before drops begin. Per-second collection of backend health (VBE.*.happy) alongside thread metrics lets you distinguish “backends are slow” from “pool is too small” in a single view, because the two produce identical symptoms (threads at max) but need different fixes.
Related guides
- How Varnish actually works in production: a mental model for operators
- Varnish monitoring checklist: the signals every production cache needs
- Varnish monitoring maturity model: from survival to expert
- Varnish sess_dropped vs req_dropped: HTTP/1 connection drops and HTTP/2 stream drops
- Varnish thread pool exhaustion: workers all busy, queue full, sessions dropped
- Varnish thread_queue_len above zero: requests waiting for a worker






