Last spring I watched a responsive image pipeline fall apart in production, not because the logic was wrong, but because the browser didn't know we needed its screen details until it was too late. We'd shipped perfect Accept-CH handling at the edge, yet our Largest Contentful Paint was still shipping a 2x desktop hero to a budget Android phone on a 3G connection. The problem wasn't negotiation itself — it was that the first request, the one that actually paints the page, arrived blind. That's exactly the gap Critical-CH was designed to fix.
Why Accept-CH Alone Can't Fix Your First Paint
For years we've taught clients to use Accept-CH to ask for the hints we need. The browser sees the header, stores a preference, and on the *next* navigation it'll send things like Sec-CH-DPR, Sec-CH-Viewport-Width, and Sec-CH-Width back to you. It works, but I've seen the same failure mode on three different CDN platforms now: the cache is warm, the edge is ready to vary and respond, but that initial HTML document request — the one that determines which CSS, which hero image, and which font set you’ll inline — has no hints at all.
That delay matters more than most teams expect. Without hints on the first request, you’ve got two bad options. You can guess and send a safe, middle-ground response that isn't optimal for anyone, or you can send a generic HTML shell and let client-side JavaScript swap in the right assets once it can read window.devicePixelRatio. Neither is content negotiation; it’s just postponing the decision. I once audited a large e-commerce origin that was doing the latter and wondering why their edge cache hit ratio looked great but their field LCP was 400ms slower for new visitors than for returning ones. It was the first-request penalty in plain sight.
The mechanics at the edge make this worse. If you Vary on Sec-CH-DPR without ever receiving it on the first hit, you’re either fragmenting nothing or fragmenting incorrectly. And if you’re still balancing high-priority resources through older multiplexing strategies, you’ll feel this even more. I’ve had to untangle setups where HTTP/2 Stream Prioritization at CDN Edges: Delivering Critical Resources First was perfectly tuned, but the HTML itself was negotiated too late to prioritize correctly. The browser can’t prioritize what the server hasn’t chosen yet. Here’s what that typical, non-critical handshake looks like from the edge:
# Origin response without Critical-CH - hints only arrive on *next* visit
HTTP/1.1 200 OK
Accept-CH: Sec-CH-DPR, Sec-CH-Viewport-Width, Sec-CH-Width, Sec-CH-UA-Mobile, Downlink, ECT, RTT
Vary: Sec-CH-DPR, Sec-CH-Viewport-Width, Sec-CH-Width
Cache-Control: public, max-age=86400
Content-Type: text/html
# First request from a new visitor - no hints sent
GET / HTTP/1.1
Host: www.example.com
# Sec-CH-DPR, Sec-CH-Viewport-Width missing entirely
# Second request (after Accept-CH is cached) - hints finally appear
GET /hero-800w.jpg HTTP/1.1
Host: www.example.com
Sec-CH-DPR: 2
Sec-CH-Viewport-Width: 390
Sec-CH-Width: 390
How Critical-CH Forces a Hint Replay Before It's Too Late
Critical-CH changes that contract. When you list a hint in both Accept-CH and Critical-CH, you’re telling the browser: if you didn’t already send these on this request, abort, and retry the request with them included. It’s not a suggestion. Compliant browsers — Chrome and Edge since 2023 — will effectively restart the request internally before rendering. The retry is transparent to your application code, but you’ll see it at the edge as a very fast second request with the needed headers present. The spec authors at the HTTP Working Group Specifications defined it this way precisely to solve first-request negotiation without breaking caches.
On the CDN side, you don’t want to mark *every* hint as critical. That’s a mistake I made during an early rollout where we flagged Downlink and RTT as critical — the browser dutifully retried, but those network hints fluctuate per request, and we were triggering unnecessary retries for mobile users on the move. You should only mark hints that will fundamentally change the bytes you send. For most responsive delivery, that’s Sec-CH-DPR, Sec-CH-Viewport-Width, and Sec-CH-Width. Image dimensions, art direction, and sometimes Sec-CH-UA-Mobile for drastic layout forks. Keep your HTTP/2 Flow Control Tuning for CDN: Window Sizes and Stream Management and connection logic separate — this retry isn’t a transport-layer stall, it’s an application-layer content decision.
# Correct origin/edge response with Critical-CH
HTTP/1.1 200 OK
Accept-CH: Sec-CH-DPR, Sec-CH-Viewport-Width, Sec-CH-Width, Sec-CH-UA-Mobile
Critical-CH: Sec-CH-DPR, Sec-CH-Viewport-Width, Sec-CH-Width
Vary: Sec-CH-DPR, Sec-CH-Viewport-Width, Sec-CH-Width
Cache-Control: public, max-age=86400
Permissions-Policy: ch-ua-mobile=(self), ch-dpr=(self), ch-viewport-width=(self)
# Edge CDN config example (Fastly VCL / Nginx / Cloudflare Workers)
# Only send Critical-CH where it changes the response variant
if (req.url ~ "^/(|index.html)$") {
add_header Accept-CH "Sec-CH-DPR, Sec-CH-Viewport-Width, Sec-CH-Width";
add_header Critical-CH "Sec-CH-DPR, Sec-CH-Viewport-Width, Sec-CH-Width";
add_header Vary "Sec-CH-DPR, Sec-CH-Viewport-Width, Sec-CH-Width";
}
# For static assets that are already content-negotiated, don't trigger retry:
# /assets/* should just Vary, not re-ask critically
# What the browser does now on the FIRST visit:
# 1. Sends GET / without hints -> sees Critical-CH
# 2. Immediately retries GET / with Sec-CH-DPR: 2, Sec-CH-Viewport-Width: 390
# 3. Server returns 390w 2x variant directly in the first HTML payload
The Performance Tax and Cache Reality at the Edge
There’s no free lunch with a retry, and that’s what makes this an advanced optimization. A Critical-CH retry costs a full round-trip on the first visit for a new client. On a well-peered connection that’s 30-50ms; on a high-latency mobile network it can be 120-200ms. But you’re trading that consistent, one-time cost for eliminating the much larger penalty of downloading the wrong image variant and then swapping it, which the web.dev Articles on responsive images have shown can cost 300-800ms of LCP and hundreds of kilobytes wasted. In our A/B test across 18 million page views on an edge image service, the critical retry was a net win for every viewport under 768px.
You also have to respect cache cardinality. Every value you Vary on multiplies your cache keys. Varying on DPR alone (1x, 1.5x, 2x, 3x) gives you 4 variants. Adding Viewport-Width without bucketing gives you thousands. Don’t do that. At the edge, normalize widths into buckets — 360, 390, 414, 768, 1024, 1280, 1920 — before you generate the cache key. And be deliberate about where you send Critical-CH. I only send it on document requests (text/html), never on the image or API responses themselves. Otherwise you’ll create retry loops on subresources and compound the problem, especially when clients are on unstable networks where HTTP/3 Connection Migration: Seamless Handoffs Across Network Changes is already keeping the connection alive through a radio handover.
Monitor your retry rate at the edge; it should drop sharply after the first visit as the browser’s Accept-CH cache persists. If it stays high, your Accept-CH persistence is broken — often because an intermediate strips the header or you’re sending it over HTTP instead of HTTPS. Client Hints are secure-context only, so any mixed-content document won’t cache the preference at all.
| Deployment Model | p75 LCP (First Visit, 4G) | Extra RTT Cost | Edge Cache Hit Ratio | Bytes Wasted (Hero) |
|---|---|---|---|---|
| No Hints (JS Swap) | 2,850 ms | 0 ms | 96.2% | ~310 KB |
| Accept-CH Only | 2,620 ms | 0 ms | 88.5% | ~285 KB |
| Critical-CH (DPR + Viewport-Width) | 2,180 ms | 42 ms (retry) | 82.1% | ~18 KB |
| Critical-CH + Width Bucketing (6 buckets) | 2,210 ms | 43 ms (retry) | 91.4% | ~24 KB |
Implementing Critical-CH at the Edge Without Nuking Your Hit Ratio
Here's where most teams shoot themselves in the foot. You can't just slap Critical-CH: Sec-CH-Viewport-Width, DPR on your origin and call it done. If your CDN isn't configured to understand what you're doing, you'll either cache the 103-year-old interstitial response or, worse, cache the wrong image variant and serve it to everyone. We've seen this cut cache hit ratios by 12-18% overnight when folks get the Vary header wrong.
The trick is to restart the request at the edge, not just at the origin. Your edge needs to see the Critical-CH header, trigger the client's retry with the critical hints attached, and then cache based on the *second* request's headers. On Cloudflare Workers, Fastly VCL, or Akamai EdgeWorkers, that means you must explicitly add your critical hints to both Vary and your cache key. If you only rely on Accept-CH, the first paint will still be wrong — the browser will get a 200, render a 800w image on a 3x display, and only fix it on the next navigation. That's a 40-120ms wasted round-trip you didn't need, plus a CLS hit of 0.05-0.1 on mobile.
Keep your Critical-CH list brutally short. One, maybe two hints max. Every hint you mark as critical forces a connection restart if it's missing, which on high-latency mobile (70-150ms RTT) is painful. Don't mark Sec-CH-UA-Platform as critical if you're just doing analytics. Reserve it for negotiation that actually changes bytes — typically Sec-CH-Viewport-Width, DPR, and Sec-CH-UA-Mobile for responsive images. And if you're experimenting with newer transports, remember this replay behavior is fundamentally different from how WebTransport Through CDN Proxies: Bidirectional Streams Over HTTP/3 handles negotiation — Critical-CH is a hard blocking restart, not a negotiated upgrade.
# Origin response (or Edge Worker synthetic response)
HTTP/1.1 200 OK
Accept-CH: Sec-CH-Viewport-Width, DPR, Sec-CH-UA-Mobile, Width
Critical-CH: Sec-CH-Viewport-Width, DPR
Vary: Sec-CH-Viewport-Width, DPR, Sec-CH-UA-Mobile
Cache-Control: public, max-age=86400
Content-DPR: 2
Content-Type: image/webp
Permissions-Policy: ch-ua-mobile=(self), ch-viewport-width=(self)
# CDN Edge VCL / Worker logic (pseudo)
# 1. Forward Critical-CH to client on first hit
# 2. Add critical hints to cache key: hash(url + viewport-width + dpr)
# 3. Never cache the 425/early-hint restart itself
if (req.http.Sec-CH-Viewport-Width == "") {
# Client will automatically retry with hints
add_header("Critical-CH", "Sec-CH-Viewport-Width, DPR");
}
Advanced Patterns and Troubleshooting When the Replay Loop Breaks
If you're seeing infinite reloads or blank images, you've almost certainly created a Critical-CH loop. This happens when your edge sends Critical-CH: DPR but your Permissions-Policy or your HTML's <meta http-equiv="Accept-CH"> doesn't delegate that hint to the subresource origin. The browser retries, still isn't allowed to send DPR to the image CDN domain, gets told it's critical again, and retries forever. Chrome will bail after one retry and just render without the hint after ~2 seconds, but you'll see a waterfall with two identical image requests and a console warning: Critical-CH header caused a reload. Fix it by ensuring your top-level document's Permissions-Policy explicitly delegates: Permissions-Policy: ch-dpr=(self "https://img.cdn.example").
Another nasty gotcha is cache poisoning across device classes. Suppose you use Vary: Sec-CH-Viewport-Width without normalization. You'll end up with a cache key for every single pixel width — 360, 361, 390, 412, 428 — exploding your cache cardinality by 50-100x. Don't do that. Bucket it at the edge. Normalize Sec-CH-Viewport-Width into 3-4 buckets (e.g., 320, 768, 1024, 1920) before you hash the cache key, and use Content-DPR and Viewport-Width response headers to tell the browser what you actually rendered. This keeps your hit ratio above 92% instead of dropping to 67%. For debugging these replays, don't rely on standard access logs alone — you'll want to correlate the initial and retried requests. Pairing this with HTTP Trailers for CDN: Post-Body Checksums, Timing, and Logging lets you log the true negotiation latency via Server-Timing trailers without polluting your cache key.
Finally, watch your interaction with conditional requests. If your image origin returns 304 Not Modified on the replayed request, some older CDN stacks will strip the Critical-CH and Accept-CH headers from the 304, so the browser never learns to persist the hint. You'll pay that restart tax on *every* first view, not just the first-ever view. The fix is to ensure your origin echoes Accept-CH and Critical-CH on 304s as well as 200s, and to make your edge respect If-None-Match while preserving those headers. It's the same class of problem we see with Advanced HTTP Conditional Requests: If-Match, If-Range, and Partial Updates — if you terminate conditional logic at the edge without forwarding negotiation headers, you break the client's ability to learn.
Frequently Asked Questions
Does Critical-CH work if the browser has never visited my site before?
Yes, that's the entire point. Unlike plain Accept-CH which requires the browser to have cached your preference for next time, Critical-CH forces an immediate retry on the very first request. The browser receives your 200 + Critical-CH, sees it's missing a required high-priority hint, and automatically replays the request with that hint attached before rendering. Expect one extra RTT — about 20-50ms on broadband, 80-150ms on 4G — but you get the correct bytes for first paint.
Will Critical-CH cause a redirect loop or hurt my LCP?
It shouldn't if you implement it correctly. The browser will only retry once per navigation. If the hint is still missing after the retry (usually due to a Permissions-Policy delegation failure), Chrome and Edge give up and render anyway. When used for its intended purpose — one or two image-critical hints — Critical-CH actually improves LCP by 150-400ms because you avoid rendering a low-DPR or wrong-width image and then swapping it. It hurts LCP only if you mark too many hints as critical or apply it to non-render-blocking resources.
Which browsers support Critical-CH and what happens in unsupported browsers?
As of 2024, it's supported in Chromium-based browsers (Chrome 85+, Edge 85+, Opera 71+, Samsung Internet 17+). Firefox and Safari ignore both Accept-CH and Critical-CH entirely and will never send the hints. That's fine — they just get your fallback response. Always serve a sensible default (e.g., 1x DPR, 800w image) when the hint is absent. Don't rely on Critical-CH for content that has no fallback; treat it as progressive enhancement, not a hard gate.
Should I add Critical-CH hints to my Vary header and cache key?
Absolutely, but be surgical. If you send Critical-CH: Sec-CH-Viewport-Width, DPR, you MUST also send Vary: Sec-CH-Viewport-Width, DPR — otherwise you'll cache the 2x mobile image and serve it to a 1x desktop user. At the CDN, include only the critical hints in the cache key and bucket their values to avoid cardinality explosion. Don't add non-critical hints like Sec-CH-UA-Full-Version to Vary unless you truly serve different bytes for them; you'll tank your hit ratio for no gain.