Sizing Envoy Worker Threads and Connection Pools
Envoy’s default configuration adapts to the machine it lands on, which is convenient until the machine is a container with a CPU limit the runtime cannot see. The result is a proxy that starts as many worker threads as the host has cores, gets throttled by the cgroup, and produces latency spikes that look like upstream problems. This page covers the three numbers worth setting explicitly — worker concurrency, connection pool limits and buffer sizes — and how to derive each from something you can measure.
Prerequisite concepts
This assumes the throughput and memory model in scaling limits and capacity planning, and the execution model comparison in Kong vs Tyk vs Envoy for microservices for why Envoy’s thread model differs from a worker-process one.
Worker threads and the container limit
Envoy runs one event loop per worker thread and distributes accepted connections between them. Each worker is independent: its own connection pools, its own circuit-breaker counters, its own share of the listener’s connections.
# Envoy 1.32+ — set concurrency to the CPU limit, not the host core count
# Command line, since it is a bootstrap concern:
# envoy -c /etc/envoy/envoy.yaml --concurrency 4
#
# In Kubernetes, derive it from the limit you already declared:
resources:
limits: { cpu: "4", memory: "4Gi" }
requests: { cpu: "4", memory: "4Gi" } # equal: avoid throttling under contention
Round up rather than down when the limit is fractional — a 1.5 CPU limit is better served by two workers than by one, because a single worker cannot use more than one core no matter how much quota is available.
Connection pools are per worker
The circuit-breaker thresholds that bound an upstream cluster are enforced per worker, which means the effective fleet-wide limit is the configured value multiplied by workers multiplied by pods. This catches teams out in both directions: too permissive against a fragile upstream, or so restrictive that a single worker queues while others idle.
clusters:
- name: orders_cluster
connect_timeout: 1s
circuit_breakers:
thresholds:
- priority: DEFAULT
max_connections: 512 # per worker, per pod
max_pending_requests: 64 # queue depth before shedding
max_requests: 200 # concurrent requests in flight
max_retries: 8 # concurrent retries, not retries per request
upstream_connection_options:
tcp_keepalive: { keepalive_time: 60 }
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config:
http2_protocol_options:
max_concurrent_streams: 100
max_pending_requests deserves particular attention: it is the queue in front of the upstream, and a large value converts a capacity problem into a latency problem. Keeping it small — tens rather than thousands — means the proxy sheds load quickly instead of holding requests that will time out anyway, which is the behaviour described in circuit breaking and retry budgets.
Buffers and the memory ceiling
Decision matrix
| Symptom | Likely cause | Change |
|---|---|---|
| Periodic p99 spikes, flat upstream latency | worker count above the CPU limit | set --concurrency explicitly |
| Upstream overwhelmed despite thresholds | per-worker limits multiplied by fleet size | divide the ceiling by workers × pods |
| Memory grows with connections then OOM | default per-connection buffer | set per_connection_buffer_limit_bytes |
| Requests queue while CPU is idle | max_pending_requests too large |
shrink it and shed earlier |
| One worker hot, others idle | long-lived connections pinned at accept | shorten max connection duration |
Gotchas and failure signals
Requests and limits should be equal for CPU on a latency-sensitive proxy. A pod that can burst above its request will be throttled unpredictably when neighbours claim their share, and the resulting jitter is indistinguishable from network variance on a dashboard.
Statistics are per worker too. A histogram that looks fine in aggregate can hide one worker at saturation, so alert on per-worker saturation rather than on the mean.
Connections are assigned at accept and never move. With long-lived connections, an imbalance persists for the life of those connections; a bounded connection duration is the only mechanism that rebalances them.
Raising concurrency does not raise throughput once CPU is saturated — it raises context switching. Prove the CPU is the bottleneck before changing the number, with the load-testing method in load testing an API gateway with k6.
Validation
-
--concurrencyset explicitly and equal to the container CPU limit - CPU request equals CPU limit on latency-sensitive proxies
- Circuit-breaker thresholds derived by dividing the upstream ceiling by workers × pods
-
per_connection_buffer_limit_bytesset, and memory headroom checked at peak concurrency - Per-worker saturation visible on a dashboard, not just the aggregate
- Numbers re-derived whenever the pod count or CPU limit changes
FAQ
What should I set concurrency to?
The container CPU limit, rounded up if it is fractional — not the host core count, which is what the default uses. A sixty-four core host running a four-CPU container will otherwise start sixty-four worker threads competing for four CPUs of quota, producing periodic latency spikes at the scheduler quota interval with flat upstream latency underneath.
Are circuit-breaker thresholds per worker or per process?
Per worker. The effective ceiling seen by the upstream is the configured value multiplied by the worker count and again by the pod count, so a max_requests of two hundred across four workers and six pods permits four thousand eight hundred concurrent requests. Derive the per-worker value by dividing the upstream proven ceiling by workers times pods, and re-derive it whenever either number changes.
Why is my Envoy using so much memory?
Almost always the per-connection buffer limit, which defaults to one mebibyte. At ten thousand concurrent connections that is roughly ten gigabytes of buffer alone. Setting per_connection_buffer_limit_bytes to thirty-two kilobytes brings the same concurrency to a few hundred megabytes, at the cost of more read events for large request bodies.
Should CPU requests equal CPU limits?
On a latency-sensitive proxy, yes. A pod allowed to burst above its request gets throttled unpredictably when neighbours claim their share, and the resulting jitter is indistinguishable from network variance on a dashboard. Equal request and limit trades some bin-packing efficiency for latency you can reason about.
Parent: Scaling Limits & Capacity Planning
Related
- Scaling Limits & Capacity Planning — the throughput and memory model these numbers implement.
- Load Testing an API Gateway with k6 — proving the bottleneck before changing any of them.
- Circuit Breaking & Retry Budgets — what the pending-request queue is really for.