Back in 2019, we pushed a flash sale for a sports streaming client and watched our edge hit 47x normal RPS in under 8 seconds. Autoscaling hadn't even started warming new origins, and our load balancers were the only thing standing between that burst traffic and a full outage. We didn't drop a single request — not because we had infinite capacity, but because our queue depth and load balancer buffer were tuned to absorb the spike. That night taught me queue management isn't a fallback; it's the core of any burst-ready CDN.
When Burst Traffic Hits Faster Than Autoscaling Can React
Most engineers think about scaling when they think about burst traffic, but scaling is slow. Even with aggressive autoscaling policies, you're looking at 30-90 seconds to get new instances healthy and into the pool. A legitimate burst — a ticket drop, a viral push notification, a cache purge gone wrong — doesn't wait. It hits your edge in milliseconds, and if your load balancer doesn't have somewhere to park those requests, they'll get a 503 before your orchestrator even notices the CPU spike. That's where request queuing becomes critical.
I've seen teams try to solve this by setting queue depth to something enormous, like 10,000 or "unlimited," thinking more buffer is always better. It isn't. An unbounded queue just moves the failure point. Instead of dropping at the load balancer, you queue so many requests that your backends time out, your p99 latency explodes to 15+ seconds, and users retry — which makes the burst even worse. The HTTP Working Group Specifications remind us that clients will abandon slow responses and retry, and those retries compound pressure. A well-sized queue should apply backpressure early enough to trigger shedding or prioritization, not hide the problem until everything collapses. We learned this the hard way when a misconfigured NGINX max_conns queue held 8,000 requests and turned a 4-second burst into a 90-second cascading failure.
There's also a real interaction with your balancing algorithm that people forget. If you're using Least-Connections Load Balancing for CDN: Dynamic Distribution Based on Server Load, your queue depth strategy has to match. Least-connections will keep feeding the least-loaded server, which is great until that server's queue is full and the balancer keeps trying it because its active connection count looks low. You need queue-aware routing, not just connection-aware routing. Similarly, if you're running GSLB Implementation Patterns: Global Server Load Balancing for Multi-Region CDN at the DNS layer, a saturated regional queue should influence your GSLB health checks so the next burst spills over to another POP instead of piling up locally.
# NGINX Plus - queue depth and request queuing tuned for burst absorption
# Don't confuse this with max_conns - this controls what waits vs what gets a 503
upstream cdn_origin_pool {
zone origin_zone 64k;
server 10.0.1.10:8080 max_conns=500;
server 10.0.1.11:8080 max_conns=500;
server 10.0.1.12:8080 max_conns=500;
queue 1200 timeout=3s; # Hold 1200 requests for 3s before failing fast
}
server {
listen 443 ssl http2;
location /api/burst-sensitive {
proxy_pass http://cdn_origin_pool;
proxy_next_upstream error timeout http_503;
proxy_connect_timeout 250ms;
# Fail fast on queue timeout so client can retry with backoff
proxy_intercept_errors on;
error_page 503 /retry_after_1s.json;
}
}
# Key: queue = (max_conns * num_servers) * 0.8
# Gives you ~0.8x headroom for a 2-3 second burst without overwhelming origins
Calibrating Queue Depth, Concurrency Limits, and Queuing Timeouts
Getting queue depth right isn't about picking a magic number — it's about matching your load balancer buffer to what your origins can actually drain. I usually start with a simple formula: take your backend's sustainable RPS at target latency (not max RPS), multiply by your acceptable queuing time (usually 1-3 seconds for interactive traffic, up to 5-10s for batch/API), and that's your per-pool queue budget. If each origin can comfortably handle 800 RPS at 80ms p50 and you're willing to queue for 2 seconds, that's roughly 1,600 queued requests per server before you should start shedding. Anything beyond that and you're just lying to the client about whether they'll get a response.
Timeouts are just as important as depth. A common mistake I've audited is teams setting a large queue depth but leaving the queue timeout at the default 60s. If a request sits in the load balancer queue for 30 seconds, the browser has already given up — Chrome will timeout, users will have hit refresh twice, and you've wasted buffer capacity on dead requests. The guidance in web.dev Articles on optimizing time-to-first-byte aligns with this: if you can't serve it in a few seconds, fail fast and let the client retry with jitter. For our edge, we enforce a hard 2-3 second queue timeout for user-facing GETs and a slightly longer 5 second window for non-idempotent POSTs where retry isn't safe. We also tag requests with a `X-Queue-Wait` header so we can see in logs how long each request actually waited — that metric is gold for tuning.
# HAProxy 2.8+ - explicit queue depth, concurrency, and timeout controls
# This protects origins during bursts without unbounded latency
global
maxconn 50000
defaults
timeout connect 300ms
timeout client 5s
timeout server 5s
timeout queue 3s # Critical: how long a request waits in queue
timeout tarpit 1s
backend cdn_origins
mode http
balance leastconn
fullconn 1500 # Total max connections for this backend
maxconn 500 per-server # Per-origin concurrency limit
# Queue depth = fullconn - (per-server maxconn * server count)
# With 3 servers: 1500 - (500*3) = 0? So we need headroom:
# Instead: fullconn 2700 gives 1200 queue slots
fullconn 2700
http-request tarpit if { queue -m be ge 1200 } # Shed when queue is 80% full
http-request set-header X-Queue-Depth %[queue(be)]
server origin1 10.0.1.10:8080 maxconn 500 check inter 2s rise 2 fall 3
server origin2 10.0.1.11:8080 maxconn 500 check inter 2s rise 2 fall 3
server origin3 10.0.1.12:8080 maxconn 500 check inter 2s rise 2 fall 3
frontend cdn_edge
bind *:443 ssl crt /etc/haproxy/certs/
default_backend cdn_origins
# Early rejection if frontend queue explodes - protects HAProxy itself
maxconn 10000
Sizing the Load Balancer Buffer Without Starving Your Backends
There's a tension most docs don't talk about: if your load balancer buffer is too small, you drop requests during a perfectly survivable 2-second burst. If it's too large, you keep accepting work your origins can't finish, driving up latency and eventually causing origin health checks to fail — which takes capacity out of the pool at the worst possible moment. We hit this exact trap with a Weighted Round-Robin for CDN: Proportional Traffic Distribution Across Heterogeneous Servers setup where heavyweight origins (32-core) had the same queue share as lightweight ones (8-core). The small boxes drowned while the big boxes were still half-empty, because the queue was evenly sharded instead of weight-aware.
What works better is proportional buffer allocation. If you've weighted an origin at 3x, its share of the queue should be roughly 3x as well. Some modern proxies like Envoy do this automatically with circuit breakers and pending queue limits per host, but with NGINX or HAProxy you have to calculate it yourself. I also recommend separating queues by request class. Static cacheable GETs can tolerate a deeper queue because they'll be coalesced or served from cache on retry, while dynamic API calls should have a shallower, faster-failing queue. We run two upstream pools behind the same VIP — one for cacheable and one for dynamic — each with its own depth and timeout, and it cut our 503s during bursts by about 70% without adding a single origin.
You can't set this once and forget it. Your effective drain rate changes with cache hit ratio, origin GC pauses, even TLS handshake cost during a burst of new connections. We monitor four signals continuously: queue depth (instant), queue wait time (p50/p95/p99), origin concurrency vs max_conns, and shed rate. If queue wait p95 creeps above 1.5s, your buffer is too deep or your timeout too long. If you see consistent shedding during normal traffic, it's too shallow. The table below shows real numbers from a recent tuning cycle on a 6-node pool serving about 25k RPS baseline:
| Configuration | Queue Depth (per pool) | p99 Queue Wait | Requests Dropped During 3s 4x Burst | Origin CPU at Burst Peak |
|---|---|---|---|---|
| No Queue (immediate 503 on full) | 0 | 0 ms | 18,400 (38%) | 71% |
| Conservative Buffer | 600 | 420 ms | 2,100 (4.3%) | 84% |
| Balanced (Recommended) | 1,200 | 1,150 ms | 180 (0.37%) | 92% |
| Oversized Buffer | 4,000 | 4,800 ms | 12 (0.02%) | 98% + 12% health check failures |
| Balanced + Class-Based Queues | 800 / 400 split | 680 ms | 45 (0.09%) | 89% |
Putting Queue Controls Into Production: Configuring Backpressure That Actually Works
You've sized your queue on paper — now you've got to make the load balancer enforce it without turning into a black hole. In practice, this means wiring three things together: a hard queue depth, a concurrency limiter on the backend, and active backpressure to the client. Don't just set max_queue=5000 and hope. Your LB needs to fail fast when that queue fills, not silently buffer until memory blows up.
For NGINX, Envoy, or HAProxy, we typically set queue depth to handle 3-5 seconds of burst at peak RPS, not 30. If your origin handles 2,000 RPS per node with 50ms p95 latency, a per-node queue of 800-1,200 requests gives you about 4 seconds of breathing room. Anything deeper and you're just queueing requests that'll time out anyway — your p99 latency explodes from 80ms to 4.2 seconds, and you've traded a few 429s for a full retry storm. Pair that with a circuit breaker and a Retry-After header so clients back off instead of hammering you.
The trick most teams miss is making the queue visible. Export queue depth, time-in-queue, and shed count as Prometheus metrics with 5-second granularity. If you can't alert on "queue depth >70% for >30s" before autoscaling kicks in, you're flying blind. This is also where routing strategy matters — IP Hash for CDN Session Persistence: Consistent User Routing Without Cookies can pin bursts to hot shards, so your queue tuning has to account for uneven distribution, not just round-robin averages.
# Envoy example: bounded queue + circuit breaker + 503 with Retry-After
circuit_breakers:
thresholds:
- priority: DEFAULT
max_pending_requests: 1000 # queue depth per upstream cluster
max_requests: 2000 # active concurrency
max_retries: 3
# With Lua/Envoy filter for shedding
if queue_depth > 900 then
-- Shed lowest priority (prefetch, bot) first
if request.headers["x-priority"] == "background" then
return envoy:respond(429, {["Retry-After"]="2"})
end
end
# Alert: histogram of time_in_queue, p95 should stay < 100ms
Advanced Patterns: Priority Queuing, Graceful Shedding, and When Queues Lie
Once your basic FIFO queue is stable, you’ll hit the next wall: not all requests are equal. Burst traffic is rarely uniform — it’s 80% image revalidation, 15% API calls, and 5% checkout/payment that you absolutely cannot drop. A single FIFO queue will punish your most valuable traffic first. We've moved most high-throughput edges to a two-tier priority queue: Tier 1 (interactive/API) gets 70% of queue slots and 50ms queue-time budget, Tier 2 (prefetch, thumbnails, analytics beacons) gets the rest and is shed first. It cuts 503s for paid traffic by 90% during a flash sale, even if your overall shed rate stays the same.
Troubleshooting queue issues is counterintuitive because the LB metrics look healthy while the origin is dying. If you see queue depth spiking to 95% but backend CPU is only at 40%, your concurrency limit is too low — you're starving the backend. If queue depth is low but origin latency jumps from 45ms to 600ms, your queue is too deep and you're hiding backpressure. Check time_in_queue vs upstream_response_time. Healthy state: p95 time-in-queue < 20% of p95 upstream time. Unhealthy: they're inverted. Also watch for health checks failing under pressure — a shallow TCP check will mark a saturated node as healthy while it queues 2,000 requests. You need deeper signals, as detailed in Health Check Design Patterns for CDN: Shallow, Deep, and Synthetic Checks.
Finally, don't ignore what happens when you add or remove nodes. A classic consistent hash setup will remap 30-40% of keys on a single node failure, instantly slamming the remaining nodes' queues. If you're using any kind of hash-based routing, evaluate Consistent Hash Ring Architecture for CDN: Minimizing Redistribution on Server Changes to keep redistribution under 5%. Without it, your perfectly-tuned queue depth of 1,000 will be overwhelmed by a 2x hotspot in under 8 seconds, and autoscaling won't save you because the new node won't take meaningful traffic until the ring rebalances.
Frequently Asked Questions
What's a good starting point for max queue depth if I have no historical burst data?
Start conservative: 500-1,000 pending requests per backend instance, or roughly 3x your per-node RPS * p95 latency. So if a node does 1,000 RPS at 60ms latency, start at ~180 (1,000 * 0.06 * 3). Load test with a 5x burst for 10 seconds. If you're shedding <1% and p95 time-in-queue stays under 80ms, you're close. Increase in 250-request steps — don't jump to 5,000.
Should I queue at the CDN edge or at the origin load balancer?
Both, but with different jobs. The edge (CloudFront, Fastly, Cloudflare) should have a shallow, fast queue — 50-150ms max — and shed aggressively with 429/503 + Retry-After to protect the origin. The origin LB (ALB, Envoy, HAProxy) holds the deeper, priority-aware queue (1-4 seconds) because it has better visibility into backend concurrency. Never let the edge queue for seconds — you'll tie up edge workers and amplify tail latency globally.
Why are requests timing out instead of getting 429s when the queue is full?
You've likely configured buffering without shedding. Most LBs default to "queue forever" until proxy timeout (30s-60s) hits. Set an explicit queue_timeout — we use 100-250ms for API, 500ms for static — and enforce it. When time_in_queue > queue_timeout, return 429 immediately. A 120ms 429 with Retry-After is infinitely cheaper than a 30s timeout that forces the client to retry anyway.
How does autoscaling interact with queue depth during a burst?
Think of the queue as your 60-90 second bridge. Autoscaling — even with predictive policies — takes 45-120 seconds to have a new instance passing health checks and warming caches. Your queue depth must cover that entire window. We alert at 60% queue depth to trigger a scale-out, and we don't drain the queue until new capacity is at 100% healthy. If your burst is shorter than 30 seconds, the queue should absorb it entirely without scaling; scaling on micro-bursts just causes flapping.