HTTP/1.1 vs HTTP/2 Caching Rules: What Actually Changes
Engineers migrating to HTTP/2 sometimes encounter unexpected cache misses and 304 revalidation storms that did not appear under HTTP/1.1. The root cause is rarely a difference in caching semantics — HTTP/2 inherits those unchanged from RFC 9111 — but rather transport-layer behaviours that interact with how intermediaries construct cache keys and decompress request headers.
Prerequisite Concepts
Before working through the differences, you should be comfortable with:
- How freshness and validation models determine whether a stored response is served or revalidated.
- How the complete HTTP request lifecycle maps a request through browser cache, CDN edge, and origin.
- How
Varyheader routing at CDN edges splits stored entries across request variants.
What HTTP/2 Does Not Change
Cache-Control, ETag, If-None-Match, If-Modified-Since, Expires, Vary — every caching header and the freshness-calculation algorithm defined in RFC 9111 §4–5 apply identically under HTTP/2. The protocol version is invisible to the caching model. A 304 Not Modified response reuses the cached body in exactly the same way, whether the connection carries HTTP/1.1 or HTTP/2 frames.
The following are the same under both versions:
Cache-Controldirective semantics (all ofmax-age,s-maxage,no-cache,no-store,stale-while-revalidate,immutable, etc.)- ETag and conditional-request validation
- Freshness lifetime calculation (
max-agevsExpiresvs heuristic) - The rule that
s-maxageoverridesmax-agefor shared caches and that browsers ignores-maxage
Step-by-Step: Where the Differences Appear
Step 1 — Understand HPACK Compression and Vary Cache Keys
HTTP/2 uses HPACK (RFC 7541) to compress request and response headers using a shared dynamic table maintained per-connection. When an intermediary such as a CDN terminates an HTTP/2 connection from a browser and opens a separate connection to origin, it must decompress the incoming headers before forwarding them and before matching them against cached entries.
A Vary header tells a cache to store separate responses keyed on the named request headers. For example:
Vary: Accept-Encoding
This requires the cache to reconstruct the exact value of Accept-Encoding from the HPACK-decoded headers. Buggy or misconfigured intermediaries that decompress headers imprecisely — normalising whitespace, changing capitalisation, or conflating missing-and-empty values — produce incorrect Vary key matches. The result is either a cache miss when a hit should have occurred (wasted round trip) or a wrong-variant response served from cache (functionally incorrect).
This is an implementation bug in the intermediary, not a protocol difference. The spec is identical; only the wire encoding changed.
To verify that a specific CDN is reconstructing Vary keys correctly:
# Send a request with a specific Accept-Encoding and capture the Vary response header
curl -sv --http2 -H "Accept-Encoding: gzip, br" \
https://your-domain.com/static/app.js \
2>&1 | grep -iE 'vary|accept-encoding|x-cache|cf-cache-status|age'
Then repeat with --http1.1 and compare. If the Age value grows consistently on repeated HTTP/2 requests, the cache is hitting; if Age resets to zero each time, the HPACK-decoded key is not matching.
Step 2 — Detect Connection Coalescing Side Effects
HTTP/2 allows a browser to reuse a single TCP+TLS connection for multiple origins, provided the TLS certificate is valid for all of them — most commonly via a wildcard certificate covering *.example.com. This is called connection coalescing.
When coalescing occurs, the browser’s connection-level cache state is shared across the coalesced origins. In practice this means a cache entry stored under https://a.example.com/logo.svg could be reused for a request to https://b.example.com/logo.svg if the paths and request headers match and the connection is coalesced. Under HTTP/1.1, separate TCP connections mean separate cache partitions.
Mitigate unintended coalescing with distinct hostnames that share no TLS certificate, or by setting Cache-Control: private on responses that are strictly per-origin.
Diagnose coalescing by inspecting the connection ID in Chrome DevTools:
- Open DevTools > Network, enable the Connection ID column (right-click the column header).
- Navigate to pages served from both origins in the same browser session.
- If both origins share a Connection ID, coalescing is active.
Step 3 — Remove or Disable HTTP/2 Server Push for Cacheable Resources
HTTP/2 Server Push allowed a server to proactively send resources before the client requested them. The mechanism bypasses the client’s normal cache check: a pushed resource can overwrite an existing cached entry even if that entry is still fresh, because the Push Promise frame is evaluated before the browser can consult its cache.
Chrome removed Server Push support in v106 (late 2022). Fastly, Cloudflare, and most major CDNs have dropped or deprecated it. If any origin or proxy in your stack still emits Link: <url>; rel=preload; as=script push hints, disable them for cacheable resources — they add latency, invalidate valid cache entries, and serve no practical purpose in modern browser+CDN deployments.
Check for active push in the DevTools Network tab: pushed resources appear with the Initiator set to Push / Other. They also appear in curl -v output as PUSH_PROMISE frames before the main response body.
Step 4 — Configure Headers Correctly for Both Protocol Versions
Use freshness-based caching with stale-while-revalidate rather than aggressive per-request validation. These headers are protocol-agnostic and work correctly under HTTP/1.1 and HTTP/2:
Versioned static assets (cache forever, invalidated by URL change):
Cache-Control: public, max-age=31536000, immutable
HTML and dynamic API responses (serve stale while revalidating in background):
Cache-Control: public, max-age=60, stale-while-revalidate=300, stale-if-error=86400
For the Vary header, keep it narrow. Each distinct value added to Vary multiplies the number of stored cache variants. Vary: Accept-Encoding is standard and well-handled. Avoid Vary: User-Agent — it creates thousands of fragments and causes cache fragmentation across CDN edges.
CDN configuration note: exclude HTTP/2 pseudo-headers (:method, :path, :scheme, :authority) from cache key generation. These are transport-layer constructs automatically derived from the URL; they must not appear in Vary values and must not be treated as distinct key components by intermediaries.
Protocol Comparison Diagram
Expected Output and Verification
After applying correct Cache-Control headers, run the following check under both protocol versions:
# HTTP/1.1
curl -sI --http1.1 https://your-domain.com/static/app.js \
| grep -iE 'http|cache-control|age|x-cache|cf-cache-status|vary'
# HTTP/2
curl -sI --http2 https://your-domain.com/static/app.js \
| grep -iE 'http|cache-control|age|x-cache|cf-cache-status|vary'
Expected correct output on the second and subsequent requests:
HTTP/2 200
cache-control: public, max-age=31536000, immutable
age: 847
cf-cache-status: HIT
vary: Accept-Encoding
Key indicators:
ageincrements on repeated requests — confirms the CDN is serving from cache, not re-fetching from origin.cf-cache-status: HIT(Cloudflare) orx-cache: Hit from cloudfront(CloudFront) confirm a CDN-layer cache hit.varyis present and lists onlyAccept-Encoding(or nothing), not volatile headers likeUser-Agent.- The
cache-controlvalue is identical in both HTTP/1.1 and HTTP/2 responses — protocol version must not alter the header.
Browser DevTools verification:
- Open Chrome DevTools > Network tab.
- Force-reload with
Ctrl+Shift+Rto flush the browser’s cache. - Reload normally. Right-click the column header and enable Protocol and Connection ID.
- Confirm the Protocol column shows
h2for HTTP/2 requests. - On the second reload (without Ctrl+Shift+R), the Size column should show
(memory cache)or(disk cache)for assets with longmax-age. - Consistent cache hits under
h2confirm that HPACK decompression at your CDN is not corruptingVarykey matching.
Edge Cases
-
HPACK dynamic table overflow: Highly variable
Varyheader values — for example, long, randomly structuredAcceptorAuthorizationstrings — can exceed the HPACK dynamic table capacity (default 4096 bytes). Headers that cannot fit are transmitted in full (uncompressed literal form) rather than as table references. This is a performance concern, not a correctness concern: cache key matching is unaffected because the full header value is still transmitted. -
Legacy proxy downgrades: Some reverse proxies silently downgrade HTTP/2 to HTTP/1.1 between the client-facing edge and the origin. This alters multiplexing behaviour but must not change
Cache-Controlsemantics. If you observe cache key discrepancies after traffic passes through a proxy layer, inspect whether the proxy is stripping or mutatingVary,Accept-Encoding, orETagheaders during the protocol translation. -
no-storeinteraction:no-storeis equally absolute under both protocols. It prohibits any storage of the response and supersedesmax-age,s-maxage, and all freshness directives. See the no-cache vs no-store guide for when each is appropriate. HTTP/2 clients are not exempt. -
Shared cache scope and
s-maxage: When your CDN sits between HTTP/2 browsers and an HTTP/1.1 origin, shared caches uses-maxageand ignoremax-age. The protocol version of the browser-to-CDN leg has no bearing on whethers-maxageormax-ageapplies — the cache tier determines that, not the protocol version.
Related
- Mapping
VaryHeaders to CDN Edge Routing explains how CDN edge nodes split cache storage byVaryvalues and why keeping theVaryset small reduces fragmentation. - Using
Vary: Accept-EncodingWithout Fragmenting Cache covers the oneVaryvalue that is universally safe and how to configure it correctly. - Freshness vs Validation Models Explained covers how
ETagandIf-None-Matchpower conditional revalidation — the same mechanism that triggers304responses under both protocols. - How to Calculate Cache Freshness Lifetime works through the RFC 9111 freshness algorithm that applies regardless of protocol version.