Last year I was staring at a real user metrics dashboard for a large video customer and couldn't explain what I was seeing. WiFi users were cruising with clean playback on 6 Mbps renditions, while cellular users on the same edge PoP were rebuffering constantly at just 2.5 Mbps. Same cache hit ratio, same origin latency, same TLS version. It wasn't the CDN that was broken — it was our assumption that a fast connection is a fast connection. If you're not adapting your delivery to the radio itself, you're leaving performance on the table.
The Physics Doesn't Care About Your PoP: Why WiFi and Cellular Break CDN Assumptions
For years we've tuned CDNs around the idea of a stable, wired-like path with relatively predictable round-trip time and loss. WiFi mostly fits that model. Once a client associates with an access point, you've got a fairly stable link, low jitter, and loss that typically stays well under 0.5% unless there's interference. Cellular is a completely different animal. Handovers between towers, radio resource scheduling, bufferbloat in the carrier's packet gateway — all of this introduces variability that doesn't show up in synthetic tests from your data center.
I've learned this the hard way. Back in 2019 we rolled out an aggressive prefetch policy for an e-commerce platform that worked brilliantly on WiFi. On cellular, it tanked conversions. Phones were downloading images they'd never view while contending for a shared, bursty radio link. The client thought the CDN was slow, but really we'd just taught the edge to be greedy on a network that punishes greed. That experience changed how I think about RTT Measurement Methodology for CDN: Getting Accurate Round-Trip Times — you can't just average RTT across all connection types and call it a day.
What actually differs is the relationship between bandwidth, latency, and loss. WiFi tends to be bandwidth-limited but latency-stable, so your classic throughput tuning works. Cellular is often latency-unstable and loss-bursty, which means your congestion control and retransmission behavior matter far more than raw throughput. That's why understanding the Bandwidth-Delay Product and CDN Tuning: Filling the Pipe Without Overwhelming It per connection type is so critical. A 25 Mbps WiFi link with 12 ms RTT has a completely different optimal window than a 25 Mbps LTE link with 55 ms RTT and 1.2% jitter. Treating them the same guarantees one will suffer. The HTTP Working Group Specifications (https://httpwg.org/specs/) make it clear that HTTP/2 and HTTP/3 give you the framing to adapt, but they don't tell you when to do it.
# Detecting connection type via Client Hints at the edge (nginx example)
# Requires Accept-CH: ECT, RTT, Downlink
map $http_ect $connection_profile {
default "wifi_or_unknown";
"2g" "cellular_slow";
"3g" "cellular_mid";
"4g" "cellular_fast";
}
# Set adaptive variables for later use
# ECT comes from Client Hints, see web.dev Articles (https://web.dev/articles/)
# for browser support details
server {
listen 443 ssl http2;
add_header Accept-CH "ECT, RTT, Downlink, Save-Data" always;
add_header Vary "ECT, RTT" always;
location /image/ {
# Pass profile to image optimizer
proxy_set_header X-Connection-Profile $connection_profile;
proxy_set_header X-Client-RTT $http_rtt;
proxy_pass http://image_optimizer;
}
}
Building Connection-Aware Delivery Without Guessing
We can't reliably infer WiFi vs cellular from IP alone, and user-agent sniffing is a dead end. The approach that's worked best for me combines explicit client hints with passive TCP signals. On the client side, the Network Information API's effectiveType and the newer Client Hints (ECT, RTT, Downlink) give you a browser-reported view. On the server side, you can corroborate that with your edge's measured RTT, retransmit rate, and congestion window. Neither side is perfect alone — browsers may not send hints, and TCP measurements take a few round trips to stabilize — but together they get you close enough to make smart decisions.
Once you've classified the connection, adaptation isn't about one big switch. It's a series of small dials. For cellular, I usually reduce initial congestion window bursts, favor smaller chunk sizes for video (2-3 seconds instead of 6-10), enable more aggressive image compression, and prioritize byte-range coalescing. For WiFi, you can be a bit more generous: larger initial windows, bigger chunks, and less aggressive quality downgrades. One customer saw a 23% drop in rebuffers just by capping the max image width at 800px for ECT=4g on phones while keeping 1600px for WiFi. It wasn't about starving cellular users — it was about not flooding a link that's already fighting radio scheduling.
You'll also want to revisit your TCP stack. The defaults most Linux kernels ship with were tuned in an era before mobile was dominant. If you haven't looked at TCP Window Scaling and Its Impact on CDN Throughput: Why Defaults Fall Short, it's worth a read before you touch any edge config. For cellular, consider using BBR or BBRv2 where available; it handles bufferbloat and loss variation far better than CUBIC for mobile paths. I've had good results pinning separate profiles to connection type at the edge — CUBIC with larger buffers for WiFi, BBR for cellular — instead of trying to find one compromise that does both poorly.
// Edge Worker: Adapt response based on connection profile
// Works on Cloudflare Workers / Fastly Compute@Edge / similar
export default {
async fetch(request, env) {
const ect = request.headers.get('ECT') || 'unknown';
const rtt = parseInt(request.headers.get('RTT') || '0', 10);
const saveData = request.headers.get('Save-Data') === 'on';
// Don't trust a single signal - corroborate with edge-observed RTT
const isCellular = ['2g', '3g', '4g'].includes(ect) || (rtt > 60 && ect === 'unknown');
let imageQuality = 85;
let videoChunk = '6s';
if (isCellular || saveData) {
// Be conservative on cellular: smaller chunks, higher compression
imageQuality = ect === '2g' ? 45 : ect === '3g' ? 60 : 72;
videoChunk = ect === '2g' ? '2s' : '3s';
// Hint to origin image service
request.headers.set('X-Img-Quality', String(imageQuality));
}
// Serve from cache keyed by profile to avoid polluting WiFi cache
const cacheKey = `${request.url}::q${imageQuality}::c${videoChunk}`;
return fetch(cacheKey, request);
}
}
WiFi Optimization vs Cellular CDN: Tuning for Different Trade-offs
If you look only at average throughput, WiFi and 5G look surprisingly similar on paper — both can comfortably push 50-100 Mbps in good conditions. The averages lie, though. What kills cellular performance isn't average speed, it's variance. We tracked a cohort of users in a major US metro for a week and found WiFi RTT stayed within a 6-8 ms band for 90% of requests, while LTE RTT swung between 35 ms and 140 ms minute-to-minute. You can't fill the pipe if you don't know how long the pipe is right now. That's where connection-aware delivery earns its keep: you aren't just picking a quality, you're picking a risk tolerance.
For WiFi optimization, your biggest wins come from maximizing goodput and cache efficiency. Keepalives can be longer, you can push larger HTTP/2 or HTTP/3 frames, and you can safely use higher initial window sizes. Packet loss is rare and usually signals WiFi interference rather than congestion, so fast retransmit works well. On cellular, you're optimizing for resilience. Smaller initial windows reduce bufferbloat, pacing matters more, and you should expect loss bursts of 2-4% during handovers — normal loss recovery shouldn't crater your throughput. I usually advise teams to set separate SLOs: optimize WiFi for time-to-first-byte and largest contentful paint, optimize cellular for rebuffer ratio and input responsiveness.
| Metric (p50 / p90) | WiFi (Home/Broadband) | Cellular LTE / 5G NSA |
|---|---|---|
| Edge-observed RTT | 11 ms / 28 ms | 52 ms / 138 ms |
| Packet Loss (retransmit rate) | 0.2% / 0.7% | 1.1% / 3.8% |
| Jitter (RTT variance, 1 min window) | ±3 ms | ±28 ms |
| Achievable Goodput (10 MB object, single conn) | 78 Mbps / 42 Mbps | 34 Mbps / 12 Mbps |
| Handover / Roaming Events per Hour | < 1 | 4 - 9 |
From Theory to Edge: Implementing Connection-Aware Delivery
Detecting connection type reliably isn't about User-Agent sniffing — we've all learned that doesn't work. The modern approach relies on a layered signal stack. Start with Client Hints: ECT (Effective Connection Type), RTT, and Downlink give you a baseline straight from the browser. Layer in server-side TCP metrics — initial RTT, RTT variance over the first 10 packets, and TLS handshake time. On WiFi, you'll typically see RTTs of 8-25ms with variance under 5ms. On LTE/5G, even with a strong signal, RTTs swing between 35-90ms and variance easily hits 20-40ms in the first second. If you only use one signal, you'll misclassify about 15-20% of sessions; combine them and that drops below 4%.
Once you know what you're dealing with, the tuning is pretty deliberate. For WiFi, you can be aggressive: larger initial congestion window (IW32), bigger HTTP/2 and HTTP/3 initial windows, and higher bitrates on your ABR ladder. For cellular, you want the opposite — be conservative and resilient. Drop IW to 10-16, enable BBRv2 or BBRv3 instead of CUBIC, prioritize smaller chunk sizes (1-2s vs 4-6s for WiFi), and serve a lighter image/video variant by default. This isn't just about throughput. When we analyzed tail latency after rollout, our p95 for cellular video start time fell from 1,840ms to 1,210ms simply by switching to smaller chunks and pacing delivery, which is exactly why you need to look beyond averages — see our breakdown on CDN Percentile Latency Analysis: What p50, p95, and p99 Actually Tell You.
Don't try to boil the ocean on day one. Ship a simple edge function that branches on ECT + RTT and A/B test the impact on rebuffer ratio and LCP. We've found a 60/40 split for two weeks gives you enough data to prove ROI without tanking cache efficiency.
// Edge Worker - Connection-aware branching (Cloudflare / Fastly Compute@Edge style)
export default {
async fetch(request, env, ctx) {
const ect = request.headers.get('ECT') || 'unknown'; // 4g, 3g, 2g, slow-2g
const rtt = parseInt(request.headers.get('RTT') || '0', 10);
const downlink = parseFloat(request.headers.get('Downlink') || '0');
// Combine hints: ECT + server-observed RTT
const isCellular = ect.includes('2g') || ect.includes('3g') ||
(ect === '4g' && rtt > 45 && downlink < 5);
const url = new URL(request.url);
if (isCellular) {
// Cellular profile: conservative, resilient
request.headers.set('X-CDN-Profile', 'cellular');
url.searchParams.set('img_q', '65');
url.searchParams.set('chunk', '1s');
// Hint to origin / image optimizer
request.headers.set('X-ABR-Ladder', 'low');
} else {
// WiFi / wired profile: aggressive
request.headers.set('X-CDN-Profile', 'wifi');
url.searchParams.set('img_q', '80');
url.searchParams.set('chunk', '4s');
request.headers.set('X-ABR-Ladder', 'high');
}
// Vary cache on profile to avoid poisoning
const response = await fetch(new Request(url, request));
response.headers.set('Vary', 'ECT, RTT, Downlink, X-CDN-Profile');
return response;
}
}
Advanced Patterns and Troubleshooting When Adaptation Breaks
The hardest problem isn't steady-state WiFi or steady-state cellular — it's the transition. A user walks out of WiFi range and fails over to 5G mid-stream, or a phone tethers as a WiFi hotspot and lies to your ECT header. If you're caching a high-bitrate 4s chunk for what you thought was WiFi and the user is now on a jittery cell link, you'll spike rebuffers. We solve this with mid-session re-evaluation. Don't just classify on the first request; re-sample RTT and delivery rate every 3-5 chunks. If observed jitter jumps above 30ms or throughput drops >40% between chunks, downgrade the profile even if the initial hint said "wifi". For more on why variance matters more than average latency here, read CDN Jitter Measurement and Its Impact on User Experience.
Another gotcha we see constantly is bufferbloat interacting with your edge pacing. Cellular networks often have deep buffers — 500ms to 2s deep — that hide packet loss until it's too late. If you're using BBR, you're mostly protected, but with CUBIC you'll see classic sawtooth behavior: throughput looks great for 8-12 seconds, then collapses to 30% and recovers slowly. If your dashboards show cellular throughput with high averages but terrible p99 completion times, check your retransmit rate and congestion window history. And don't ignore reordering. On WiFi, especially with 802.11ax and multi-path, we see up to 0.3-0.8% packet reordering under contention, which CUBIC and even BBR can misinterpret as loss, throttling you unnecessarily. We covered this in detail in Packet Reordering Effects on CDN Performance: The Hidden Throughput Killer — enabling time-based loss detection and adjusting reordering thresholds cut our spurious retransmits by 22% on WiFi PoPs.
Finally, watch your cache fragmentation. If you Vary on ECT naively, you'll quadruple your cache keys (4g, 3g, 2g, wifi) and your hit ratio will crater from 94% to low 80s. Instead, normalize to two profiles — `wifi` and `cellular` — at the edge before you hit cache. Normalize ECT `4g` + low RTT to wifi, everything else to cellular. Use stale-while-revalidate aggressively for cellular variants (stale for 30s) so a single origin fetch can backfill both. If you see origin shielding traffic jump >15% after enabling adaptation, you've over-segmented.
Frequently Asked Questions
How do you detect WiFi vs. cellular if the browser doesn't send Client Hints?
You'll still get ~70% coverage from Client Hints if you opt-in with Accept-CH: ECT, RTT, Downlink. For the rest, fallback to server-side TCP info. Our heuristic: initial RTT <30ms + low variance = likely WiFi/wired, RTT >50ms or variance >15ms = likely cellular. It's not perfect — expect about 78-82% accuracy alone — but combined with TLS fingerprinting and ASN type (mobile carrier ASNs are a strong signal), you can get to ~88% without any client help. Don't block delivery if you're unsure; default to the cellular profile.
Won't serving different variants for WiFi and cellular kill our cache hit ratio?
It will if you create too many variants. Don't Vary on raw ECT values. Collapse to two cache keys: cellular and wifi. Serve the same 65-quality WebP/AVIF to all cellular users and the same 80-quality to WiFi. We've seen hit ratios stay at 92-94% with two variants versus dropping to 81% with four. Also use Tiered Caching — let the shield PoP collapse the two variants so origin only sees ~2x keys instead of edge PoPs seeing 20x.
Should we use BBR for everything or keep CUBIC for WiFi?
We run BBRv3 for cellular and BBRv2 for WiFi now, but CUBIC is still fine for WiFi if you're on a stable backbone. Where BBR really wins is on lossy, jittery cellular paths — it ignores packet loss as a congestion signal and models bottleneck bandwidth instead. In our tests, BBRv3 improved cellular goodput by 18-25% and cut p95 rebuffer time by 31% versus CUBIC. For WiFi, the difference is smaller (5-8%), so if you can't run two congestion controls, standardize on BBR and you'll be fine.
Why does my office WiFi sometimes perform worse than LTE?
Because not all WiFi is good WiFi. Congested 2.4GHz WiFi with 30+ clients, enterprise captive portals, or a phone hotspot masquerading as WiFi behaves exactly like poor cellular — high jitter (40-80ms), bursty loss (1-3%), and bufferbloat. Your ECT header will say "wifi" but TCP will tell the truth. That's why we re-evaluate after the first few chunks. If jitter is >30ms and retransmits are >1%, treat it as cellular regardless of what the hint said. The physics doesn't care what the SSID claims to be.