Request Collapsing in Varnish with VCL
When a thousand clients request the same expensive, currently-uncached URL within milliseconds of each other, a naive cache forwards all thousand requests to the backend simultaneously — a thundering herd that can take origin down. Varnish avoids this by default through request collapsing: concurrent misses for the same object are queued and served from a single backend fetch. This page covers how that mechanism actually works in VCL, how to inspect it with varnishlog, and how to deliberately opt specific requests out of it.
Prerequisite Concepts
This page assumes familiarity with:
- Origin Shielding and Request Collapsing — the general thundering-herd problem this mechanism solves, at both the single-node and CDN-shield level.
- How CDN Cache Keys Are Generated — Varnish’s collapsing is keyed on the same hash used for cache lookups, so anything that changes the hash changes whether requests collapse.
- Mapping
VaryHeaders to Edge Routing —Varyinteracts directly with the hash Varnish uses, which is central to this page’s discussion of collapsing failures.
Step-by-Step Resolution
Step 1 — Confirm the default collapsing behavior
Varnish ships with built-in VCL (builtin.vcl) that your custom vcl_recv, vcl_hash, and vcl_miss subroutines implicitly fall back to via return. The relevant default logic lives in vcl_hash and the backend-fetch bookkeeping around vcl_miss:
sub vcl_hash {
hash_data(req.url);
if (req.http.host) {
hash_data(req.http.host);
} else {
hash_data(server.ip);
}
return (lookup);
}
hash_data() calls accumulate into a single object hash. When vcl_miss returns fetch, Varnish’s cache manager checks whether another thread is already fetching an object with the identical hash. If so, the new request is not dispatched to the backend — it is parked on that object’s waiting list. When the in-flight fetch completes, every parked request is served from the newly stored (or newly marked pass) object in one pass. This behavior requires no custom VCL at all; it is built into Varnish’s object and busy-object bookkeeping.
Step 2 — Inspect the waiting list with varnishlog
To see collapsing happen live, generate concurrent requests for a currently-uncached URL:
for i in 1 2 3 4 5; do curl -s -o /dev/null https://example.com/expensive-report & done
wait
While that runs, watch varnishlog filtered to the request:
varnishlog -g request -q 'ReqUrl eq "/expensive-report"'
Only one entry in the log shows a BereqURL (a real backend request line) for /expensive-report; the rest show the request being served without their own backend fetch. Cumulative counters confirm the same thing across the whole server:
varnishstat -1 | grep -E 'busy_(sleep|wakeup)'
MAIN.busy_sleep 4 0.00 Number of requests sent waiting due to busy object
MAIN.busy_wakeup 4 0.00 Number of requests woken after waiting for busy object
busy_sleep increments once per request that had to queue on the waiting list; busy_wakeup increments once each is released after the fetch completes. In the five-request burst above, four collapsed onto the fifth’s in-flight fetch.
Step 3 — Check how Vary and custom hashing affect collapsing
Collapsing only happens for requests that hash to the same object. If the backend response carries a broad Vary header, or if vcl_hash includes request data that differs per client, concurrent requests stop colliding:
sub vcl_hash {
hash_data(req.url);
hash_data(req.http.host);
# Dangerous: including the full Cookie header fragments the hash per client
if (req.http.cookie) {
hash_data(req.http.cookie);
}
return (lookup);
}
With per-client cookies hashed in, five concurrent requests for the same URL produce five distinct hashes — none collapse, and the backend sees five simultaneous fetches, exactly the thundering-herd scenario collapsing is meant to prevent. If personalization genuinely requires per-user cache entries, only hash the specific cookie(s) that matter, not the entire header:
sub vcl_hash {
hash_data(req.url);
hash_data(req.http.host);
if (req.http.cookie ~ "session_region=") {
hash_data(regsub(req.http.cookie, ".*session_region=([^;]+).*", "\1"));
}
return (lookup);
}
This keeps the hash space small (one entry per region rather than one per user), preserving collapsing for concurrent requests that share a region.
Step 4 — Disable collapsing where it is actively harmful
Not every request should collapse. Genuinely uncacheable, per-user, or write-triggering requests should bypass the object cache entirely with return(pass), so Varnish never attempts to build a shared cache entry for them:
sub vcl_recv {
if (req.url ~ "^/account/" || req.http.authorization) {
return (pass);
}
}
A pass request is fetched directly from the backend on every occurrence, with no waiting list and no attempt to store the result. This is the correct choice for authenticated, per-user responses where collapsing would either be impossible (each request differs) or dangerous (serving one user’s personalized response to another).
The subtler case is a response that looks cacheable when the request arrives, but turns out to be uncacheable once the backend responds — for example, the backend sets Set-Cookie or a short-lived error status. Left unhandled, every request queued on the waiting list still ends up with a response that cannot be reused, and the next miss triggers the same expensive backend call again. Varnish solves this with hit-for-pass objects:
sub vcl_backend_response {
if (beresp.http.Set-Cookie || beresp.status >= 500) {
set beresp.uncacheable = true;
set beresp.ttl = 10s;
return (deliver);
}
}
Setting beresp.uncacheable = true stores a hit-for-pass object at that hash for the given TTL. Subsequent requests for the same hash see the hit-for-pass marker in vcl_hit and are converted straight to pass behavior — they skip the waiting list entirely instead of queuing behind an object that will never be cacheable.
Expected Output / Verification
varnishlogshows exactly oneBereqURLper concurrently-requested, currently-uncached object; all other client requests for that same object show no independent backend fetch.varnishstat -1 | grep busy_showsbusy_sleepandbusy_wakeupincrementing together under concurrent load, confirming the waiting list is active.- After adding
Vary: Cookie(or hashing the full cookie invcl_hash), the same concurrency test produces oneBereqURLper request instead of one — collapsing has stopped. - After adding the hit-for-pass rule for
Set-Cookieresponses, a burst of concurrent requests against an endpoint that sets cookies shows each request passed through independently with nobusy_sleepincrements, confirming the waiting list is being avoided rather than queuing behind an uncacheable fetch.
Edge Cases
- Hit-for-pass TTL window — a short hit-for-pass TTL (commonly 2 seconds) means a fresh burst of concurrent misses arriving just after it expires can still momentarily thunder the backend before a new hit-for-pass object is created. Tune the TTL to the shortest value that still spans your typical burst window.
- Long backend response times amplify queue depth — the waiting list holds requests for the full duration of the in-flight backend fetch. A slow backend (multi-second response time) combined with high concurrency can exhaust Varnish’s worker thread pool even though only one backend connection is open, because parked requests still hold a worker thread each while waiting.
Varyinteracts withhash_data, not the other way around — Varnish does not automatically incorporateVaryinto the request-side hash the way a browser cache does; you must explicitly mirror the relevantVarydimensions invcl_hashfor correct cache/hash alignment, and every dimension you add is a dimension collapsing has to match on.- Collapsing does not apply across separate Varnish nodes — if you run multiple Varnish instances behind a load balancer without a shared cache, each node maintains its own waiting list. A request pattern that appears collapsed within one node’s logs can still generate one backend fetch per node.
Frequently Asked Questions
Does Varnish collapse requests by default without any custom VCL?
Yes. The built-in vcl_miss and vcl_hash logic already implements request coalescing: concurrent requests that hash to the same object queue on a waiting list while one thread fetches from the backend, and all queued requests are served from the resulting object once it lands.
What is a hit-for-pass object?
A hit-for-pass object is a short-lived marker Varnish stores when a backend response turns out to be uncacheable (beresp.uncacheable = true). It tells Varnish to treat subsequent requests for the same hash as pass requests directly, rather than queuing them on a waiting list behind an object that will never be cached.
Why does adding Vary: Cookie break request collapsing?
Vary: Cookie makes the effective cache key depend on the full Cookie header. Since cookies are typically unique per client, almost every request produces a distinct hash, so concurrent requests rarely collapse onto the same waiting list — each effectively becomes its own miss.
Should I use return(pass) or a hit-for-pass object for authenticated endpoints?
Use return(pass) in vcl_recv when you can identify the request as uncacheable up front (an Authorization header, a known account path). Use a hit-for-pass object in vcl_backend_response when you can only tell after the backend responds — for example, when the backend unexpectedly sets Set-Cookie on what looked like a cacheable request.
Related
- How to Debug CDN Cache Key Mismatches — the broader diagnostic process for tracking down why requests that should share a cache entry are not colliding.
- Configuring Origin Shield on Cloudflare and Fastly — the equivalent shield-tier collapsing behavior when the layer in front of origin is a managed CDN rather than a self-hosted Varnish node.
- Using
Vary: Accept-EncodingWithout Fragmenting Cache — a worked example of keeping aVarydimension narrow enough to preserve both correctness and collapsing. - Public vs Private Cache Scope — clarifies which responses are eligible for shared-cache storage at all, a prerequisite for collapsing to apply.