Advanced Client Hints for CDN: Device Memory, Network Quality, and Viewport Signals

Last Black Friday, we watched a product page collapse on low-end Android devices — not because the origin was slow, but because we shipped a 2400px hero image and 380KB of JavaScript to a phone with 1GB of RAM on a throttled 3G link. Our edge cache was hitting 98% and response times looked fine in Datadog, yet real users bounced before first paint. That incident forced us to stop guessing from User-Agent strings and start listening to what the browser was actually telling us.

Why User-Agent Sniffing Fell Apart and What Replaced It

I’ve spent the last ten years tuning CDN configs, and for most of that time we relied on User-Agent parsing to guess device capability. It never worked well. UA strings are noisy, frozen, and now actively reduced for privacy. You’d end up with brittle regex at the edge that broke every time Apple or Chrome shipped a new version. Client Hints were designed to fix this by moving from passive guessing to an explicit, opt-in negotiation. The browser only sends the signals you ask for, and it sends them as structured, low-entropy headers you can trust at the edge.

Here’s how it works in practice. You advertise the hints you want with Accept-CH on your first response, and for hints that affect above-the-fold rendering, you can use Critical-CH to tell the browser to restart the connection with those hints included. The browser then sends Sec-CH-* headers on subsequent requests. Unlike the old UA approach, you don’t have to maintain a device database, and you don’t pay the cost of client-side JavaScript to measure the viewport. The details are well documented in the HTTP Working Group Specifications and the practical guides on web.dev Articles, which I’d recommend reading before you roll this out.

The catch is caching. Every hint you vary on fragments your cache. I learned this the hard way in 2021 when we added Sec-CH-DPR and Sec-CH-Viewport-Width to our Vary header without thinking, and our cache hit ratio dropped from 92% to 61% overnight. You have to choose which hints actually change the response body and handle the rest via secondary logic or at the edge worker level. And if you’re still tuning how assets are multiplexed once you’ve made those decisions, HTTP/2 Flow Control Tuning for CDN: Window Sizes and Stream Management is essential context for avoiding window stalling on those varied responses.

# Origin response - advertise hints you need (Apache / Nginx / VCL)
# This goes on HTML responses and critical asset responses
Accept-CH: Sec-CH-Device-Memory, Sec-CH-DPR, Sec-CH-Viewport-Width, Sec-CH-ECT, Sec-CH-RTT, Downlink
Critical-CH: Sec-CH-Device-Memory, Sec-CH-Viewport-Width
Permissions-Policy: ch-device-memory=(self), ch-viewport-width=(self), ch-ect=(self)

# Vary only on hints that change the cached body
Vary: Sec-CH-Device-Memory, Sec-CH-DPR, Sec-CH-Viewport-Width

# CDN cache key customization (example: Fastly VCL / Cloudflare Cache Key)
# Don't blindly add all hints to the cache key - be selective
if (req.http.Sec-CH-Device-Memory) {
  # Bucket memory to avoid explosion: 0.5, 1, 2, 4, 8
  set req.http.X-Memory-Bucket = regsub(req.http.Sec-CH-Device-Memory, "^([0-4]).*", "\1");
}

Adapting Payloads to Device Memory and Network Quality

Sec-CH-Device-Memory and the network hints — Sec-CH-ECT (Effective Connection Type), RTT, and Downlink — are the two signals that have saved us the most bandwidth. Device Memory is rounded to the nearest power of two (0.25, 0.5, 1, 2, 4, 8) to preserve privacy, but that bucketing is actually helpful at the edge. We don’t serve five different bundles; we serve two. If Device-Memory <= 1, we send a lite JavaScript bundle without the heavy carousel and recommendation widget, and we defer non-critical CSS. On one retail client, that cut Total Blocking Time by 620ms on low-end devices and dropped JavaScript parse errors by 40%.

Network quality hints are trickier because they’re estimates, not guarantees. ECT will give you 4g, 3g, 2g, or slow-2g, while RTT and Downlink give you millisecond and Mbps values. I don’t use them to block content — I use them to prioritize it. On ECT: 2g or ECT: 3g with RTT > 300ms, we don’t serve a low-res image and call it a day; we change the delivery strategy entirely: we inline critical CSS, we send WebP at quality 60 instead of AVIF at quality 75 (because decode time matters on slow CPUs), and we delay preloading secondary fonts. That pairs directly with how you schedule bytes on the wire — if you haven’t already, read HTTP/2 Stream Prioritization at CDN Edges: Delivering Critical Resources First because sending a smaller payload doesn’t help if it’s still competing with low-priority streams.

You should also treat these hints as advisory and combine them with server-side metrics. ECT can report 4g even when the radio is throttled, and Downlink fluctuates quickly on mobile. We combine the hint with our own edge-measured RTT and connection coalescing data before making a decision. And remember, these hints are only sent over HTTPS and require an opt-in, so always provide a sensible default for the first navigation without them.

// Cloudflare Worker / Fastly Compute@Edge example: adaptive serving
export default {
  async fetch(req, env, ctx) {
    const memory = parseFloat(req.headers.get('Sec-CH-Device-Memory') || '8');
    const ect = req.headers.get('Sec-CH-ECT') || '4g';
    const rtt = parseInt(req.headers.get('Sec-CH-RTT') || '0', 10);
    const viewport = parseInt(req.headers.get('Sec-CH-Viewport-Width') || '0', 10);

    // Bucket decisions - keep variants small to protect cache
    const isLowMemory = memory <= 1;
    const isSlowNetwork = ect === '2g' || ect === 'slow-2g' || (ect === '3g' && rtt > 300);

    // 1. Choose JS variant
    let jsVariant = '/assets/app.full.js';
    if (isLowMemory || isSlowNetwork) {
      jsVariant = '/assets/app.lite.js'; // ~85KB vs 260KB, no carousel
    }

    // 2. Choose image quality strategy
    let imageQuality = 'q=75&fmt=avif';
    if (isSlowNetwork) imageQuality = 'q=60&fmt=webp';
    if (isLowMemory) imageQuality += '&decode=async';

    // Rewrite request to variant without redirect
    const url = new URL(req.url);
    if (url.pathname === '/') {
      req.headers.set('X-JS-Variant', jsVariant);
      req.headers.set('X-Image-Quality', imageQuality);
    }
    return fetch(req);
  }
}

Serving the Right Image Without Client-Side Hacks

For years we used JavaScript or <img srcset> to pick image sizes, which works but means the browser has to download, parse, and execute before it knows what to fetch. Sec-CH-Viewport-Width and Sec-CH-DPR let you make that choice at the CDN before a single byte of HTML is rendered. Viewport-Width tells you the layout viewport width in CSS pixels, and DPR tells you the device pixel ratio. Together, they give you the ideal image width: viewport-width * DPR, capped to your largest variant.

We bucket Viewport-Width to prevent cache explosion. We don’t cache every possible width from 320 to 1920; we round to 320, 480, 768, 1024, 1280, and 1920. A request for 412px on a Pixel 7 with DPR 2.625 gets mapped to the 1024w variant (412 * 2.625 ≈ 1081, so 1280w with compression is a better trade than 1920w). This isn’t theoretical — after moving viewport logic to the edge, we cut median LCP by 340ms on mobile and reduced image bytes by 38% without any front-end changes. If you’re delivering these images over HTTP/3, remember that viewport-adaptive responses still benefit from stable connections during resizes and orientation changes, which is where HTTP/3 Connection Migration: Seamless H

Implementing Client Hints at the Edge: Accept-CH, Vary, and Cache Keys

You can't just start reading Sec-CH-Device-Memory tomorrow and expect it to work. Client Hints are opt-in, gated behind HTTPS, and only sent after your origin or edge has explicitly asked for them. That handshake is where most teams stumble. You'll send Accept-CH: Sec-CH-Device-Memory, Sec-CH-ECT, Sec-CH-DPR, Sec-CH-Viewport-Width, Sec-CH-RTT, Downlink on your HTML response, the browser caches that preference for the origin, and on the *next* navigation it'll include the requested hints. First visit? You're flying blind unless you use Critical-CH to force a retry.

That "next visit" problem is why cache key design matters so much. Don't just Vary: Sec-CH-Device-Memory and call it a day. If you vary indiscriminately, you'll explode cardinality. Device-Memory has 6 buckets (0.25, 0.5, 1, 2, 4, 8), ECT has 4 values (slow-2g, 2g, 3g, 4g), Viewport-Width is continuous, and DPR is typically 1, 1.5, 2, 3. Vary on all four raw and you've got 6 * 4 * infinite * 4 variants per URL — say goodbye to your 92% hit ratio. At the edge, we normalize. We bucket Viewport-Width into 320, 768, 1024, 1440, 1920, we map Downlink + RTT + ECT into a single Network-Tier: good | degraded | poor header internally, and *then* we vary. You'll serve a far tighter cache footprint without losing fidelity.

And don't delegate your third-party assets into a black hole. If your images live on cdn.example.com but your HTML is on www.example.com, the hints won't cross without a Permissions-Policy / Feature Policy delegation. Add Permissions-Policy: ch-device-memory=(self "https://cdn.example.com"), ch-ect=(self "https://cdn.example.com"), ch-viewport-width=(self "https://cdn.example.com") on the top-level document, otherwise your CDN never sees the signal. For cases where you need low-latency renegotiation beyond static hints, like adapting streams in real time, we've moved that logic to WebTransport Through CDN Proxies: Bidirectional Streams Over HTTP/3.

# Edge / Origin (Nginx) - Normalize hints before they hit your cache key
# 1. Ask for hints & trigger retry on first visit for critical signals
add_header Accept-CH "Sec-CH-Device-Memory, Sec-CH-ECT, Sec-CH-RTT, Downlink, Sec-CH-DPR, Sec-CH-Viewport-Width" always;
add_header Critical-CH "Sec-CH-Device-Memory, Sec-CH-ECT" always;
add_header Permissions-Policy 'ch-device-memory=(self "https://cdn.example.com"), ch-ect=(self "https://cdn.example.com")' always;
add_header Vary "Sec-CH-Device-Memory, Sec-CH-ECT, Sec-CH-DPR, Sec-CH-Viewport-Width" always;

# 2. In VCL / Worker / Edge Function - bucket before caching
# Normalize network tier to 3 buckets to avoid 1000s of variants
# if (ECT == "4g" && RTT < 150 && Downlink > 1.5) tier=good
# else if (ECT == "4g" && RTT < 300) tier=degraded else tier=poor
# set cache key: url + tier + device-memory-bucket + viewport-bucket + dpr-bucket

Advanced Patterns and Troubleshooting: When Hints Lie or Your Hit Ratio Tanks

First thing you'll notice in production: hints lie a little. Not maliciously, but by design. For privacy, Sec-CH-Device-Memory is rounded and quantized — a device with 6GB reports as 8, a device with 3GB reports as 4. Sec-CH-RTT is rounded to the nearest 25ms, and Downlink is capped. Don't build logic that says if memory === 3.7 then.... Build tiers: low (<=1GB), mid (2GB), high (>=4GB). And remember hints are snapshots, not guarantees. A user on 4g with RTT 45ms at 10:00:01 can be on 3g with RTT 680ms at 10:00:08 going through a tunnel. If you're adapting video or large payloads, treat ECT as a hint, not a contract, and combine it with server-side throughput hints if you have them.

The second gotcha is cache fragmentation. I've seen teams enable Vary: Sec-CH-Viewport-Width raw and watch edge hit ratio drop from 88% to 34% in an hour because every phone reported a slightly different width — 390, 393, 412, 428. The fix isn't to remove Vary — that'd serve a 1920w hero to a Moto G — it's to normalize *at the edge* before the lookup and persist that normalized key. Cloudflare Workers, Fastly Compute, Akamai EdgeWorkers all let you do this: read the raw Sec-CH-Viewport-Width, bucket it, set a X-Viewport-Bucket header, and use *that* for Vary. Same for DPR. And log what you're dropping: pipe the raw hints plus your bucket decision into HTTP Trailers for CDN: Post-Body Checksums, Timing, and Logging so you can analyze mispredictions without bloating response headers.

Finally, watch your revalidation logic. When you serve a low-memory variant (say, a lite JS bundle at /app.js with Vary: Sec-CH-Device-Memory), you *must* handle conditional requests correctly or you'll poison the cache. A client with memory=8 that sends If-None-Match: "lite-etag" shouldn't get a 304 for the *heavy* variant. We solve this by making ETags variant-aware — ETag: "app-v2-mem-low" vs "app-v2-mem-high" — and honoring Vary during 304 comparison. If you're building that tiered validation layer, review Advanced HTTP Conditional Requests: If-Match, If-Range, and Partial Updates before you roll your own.

Frequently Asked Questions

Do Client Hints work on the very first request?

No, not by default — and that's the biggest foot-gun. The browser only sends Sec-CH-* headers *after* it has seen your Accept-CH. On the first visit, you'll get no hints. If you absolutely need them for above-the-fold rendering, send Critical-CH alongside Accept-CH; Chromium will then retry the request with the hints attached. It costs you one extra round trip (~40-90ms on 4g), so only use it for 1-2 critical hints like Device-Memory or ECT, not all six.

Should I use Vary or normalize at the edge?

Always normalize, then Vary on the normalized value. If you Vary directly on Sec-CH-Viewport-Width, you'll create thousands of cache keys. Instead, use an edge worker to map 0-359 to 320, 360-800 to 768, etc., set X-Viewport-Bucket, and Vary on that. Same for network quality — collapse RTT + Downlink + ECT into a single 3-tier bucket. You'll keep your hit ratio above 85% while still delivering the right payload 95% of the time.

Why am I not seeing Client Hints from my image CDN domain?

Two reasons: 1) You're on HTTP — hints are HTTPS-only, and 2) You haven't delegated. If your HTML is on www.example.com and your images are on cdn.example.com, the hints are scoped to www. Add a Permissions-Policy header on the HTML document that explicitly delegates to the CDN origin: Permissions-Policy: ch-device-memory=(self "https://cdn.example.com"). Without it, the browser will strip the hints on cross-origin subrequests.

Can I trust Downlink and RTT for adaptive streaming?

As a coarse signal, yes; as a precise bitrate controller, no. Downlink is the browser's estimate in Mbps (often rounded to 0.5-10 Mbps) and RTT is quantized to 25-50ms steps. They're great for choosing between a 1.2MB vs 300KB JS bundle or 800w vs 2000w image. They're not accurate enough for per-segment ABR decisions. For that, use client-side throughput estimation or server-side bandwidth measurement and let ECT just set your initial tier.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker