Mobile Connection Detection and Switching: Adapting CDN Behavior to Network Type

I was on a call with a client last year while their QA team rode the Mumbai local trains, streaming video through our edge. Every time the train slipped from Wi-Fi at a station to 4G and then down to 3G between stops, the player froze for 8-10 seconds. It wasn't a codec issue or a cache miss. Their app had no idea the network had changed, so the CDN kept trying to push 1080p chunks over a connection that couldn't handle 240p. We've all been there — building for perfect Wi-Fi in the office while users live on flaky mobile networks.

Why Network Information APIs Can't Be Trusted Blindly

When I first started working on mobile delivery back in 2014, we had essentially zero visibility into the client's connection. Today we have the Network Information API, Client Hints, and a handful of native APIs on iOS and Android, but I've learned not to trust any single signal on its own. The browser's navigator.connection.effectiveType is the most common starting point — it gives you 4g, 3g, 2g, or slow-2g — but it's an estimate, not a measurement. It buckets based on recent RTT and downlink observations, and it can be stale or completely wrong on VPNs or corporate proxies.

On Android WebView and Chrome, you'll also get downlink (in Mbps), rtt (in ms), and saveData if the user has Data Saver enabled. iOS Safari doesn't expose most of this, which is a real headache if your audience is 60% iPhone users, as one of my e-commerce clients was. That's why we never make CDN decisions solely in the browser. A better pattern is to detect on the client, pass it as a Client Hint or custom header, and let the edge validate it against real TCP metrics. The specs behind this are well documented in the HTTP Working Group Specifications and summarized nicely in web.dev Articles on network quality.

For native apps, the story is cleaner. If you're building with a proper SDK, you can get precise radio access technology — LTE, NR (5G), HSPA, EDGE — directly from the telephony manager. I generally recommend handling that detection inside your Mobile CDN SDK Integration Guide: Adding Edge Acceleration to iOS and Android Apps implementation rather than trying to polyfill it in JavaScript. The SDK can then attach a header like X-Network-Type or use the standard ECT Client Hint so the edge can adapt without extra round trips.

// Don't rely on effectiveType alone — combine signals and observe changes
function getNetworkProfile() {
  const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
  if (!conn) return { type: 'unknown', downlink: 0, rtt: 0 };

  return {
    type: conn.effectiveType, // '4g' | '3g' | '2g' | 'slow-2g'
    downlink: conn.downlink,  // Mb/s estimate
    rtt: conn.rtt,            // ms estimate
    saveData: conn.saveData || false
  };
}

// Send as Client Hint / custom header so CDN edge can adapt
async function fetchWithNetworkHint(url) {
  const net = getNetworkProfile();
  const headers = new Headers({
    'ECT': net.type,
    'Downlink': String(net.downlink),
    'Save-Data': net.saveData ? 'on' : 'off'
  });
  
  // Edge will use this to select image quality / chunk size
  return fetch(url, { headers });
}

if (navigator.connection) {
  navigator.connection.addEventListener('change', () => {
    console.log('Network changed:', getNetworkProfile());
  });
}

Handling Mid-Session Network Switches Without Killing UX

Detection is the easy part. Switching is where apps break. I audited a news app earlier this year that cached aggressively on Wi-Fi, which was great — until the user walked out of their office and onto LTE. The app kept serving stale, high-bitrate assets from memory cache while new API requests timed out because they were still sized for Wi-Fi. They'd never listened for the change event, so the Mobile App CDN Caching Strategies: Disk Cache, Memory Cache, and Network Cache Layers logic never re-evaluated.

You can't just pause everything when the network drops to 2G. What works better is a tiered response. On a switch event, I typically abort non-critical prefetches immediately, downgrade in-flight image requests if possible, and let the CDN know to adjust for the next request. For video or large file downloads, we use range requests to resume instead of restarting — the HTTP specs make this straightforward, but you have to implement it. If you don't handle it, the browser will happily keep a 5MB image download hanging for 30 seconds on a 2G connection while the user stares at a spinner.

One pattern that's saved us multiple times is debouncing the switch handler. Radios flap. You'll get a quick hop from 4G to 3G back to 4G when someone passes through an elevator. If you react instantly to every event, you'll thrash your adaptation logic. We wait about 2-3 seconds and confirm the new profile is stable before we tell the CDN to switch variants. For apps that must stay usable with no signal at all, pair this with an Offline-First Architecture with CDN: Building Apps That Work Without Connectivity approach so you've got a cached shell to fall back to instead of a blank screen.

// Graceful switching: debounce, abort low-priority work, resume with ranges
let switchTimer = null;
let activeController = null;

function onNetworkSwitch(newProfile) {
  clearTimeout(switchTimer);
  switchTimer = setTimeout(async () => {
    // 1. Abort low-priority prefetches
    if (activeController) activeController.abort();

    // 2. Tell the edge about the new profile for *next* requests
    //    (use fetch with updated ECT / Save-Data headers)

    // 3. Resume large downloads with Range header instead of restarting
    if (newProfile.type === '2g' || newProfile.type === 'slow-2g') {
      // Switch to low-quality variant
      await fetchAsset('/api/feed?image_quality=low&chunk=small', { priority: 'high' });
    } else if (newProfile.type === '4g' && !newProfile.saveData) {
      await fetchAsset('/api/feed?image_quality=high&chunk=large', { priority: 'high' });
    }
  }, 2500); // debounce flapping radios
}

// Hook it up
if (navigator.connection) {
  navigator.connection.addEventListener('change', () => onNetworkSwitch(getNetworkProfile()));
}

// Example resumption for a large file
async function resumeDownload(url, bytesReceived) {
  return fetch(url, {
    headers: { 'Range': `bytes=${bytesReceived}-` }
  });
}

How We Adapt CDN Behavior For Each Network Type

Once you've got reliable signals and a stable switch handler, the edge can do the heavy lifting. We don't try to be clever with dozens of variants — three tiers plus a Save-Data override covers 95% of cases. On fast Wi-Fi or 4G without Save-Data, we serve WebP/AVIF at high quality, larger edge TTLs, and we allow aggressive prefetch. On 3G, we drop image quality to around 60-70%, cap max image width, and disable autoplay video. On 2G or slow-2G, or when Save-Data: on is present regardless of network type, we serve ultra-light variants, tiny JSON payloads, and we never prefetch.

The CDN piece is what makes this scalable. Instead of baking all the logic into the app, we use Vary on ECT and Save-Data at the edge and let the cache keep separate variants. That way a user on 2G doesn't pollute the cache for a user on Wi-Fi. We also adjust origin shielding and stale-while-revalidate windows — on slow networks we serve stale for longer rather than blocking on a slow origin fetch. It's a trade-off, but users on bad connections would rather see slightly stale content instantly than wait 4 seconds for fresh content.

What tripped us up for years was not measuring the impact. You need real user metrics split by effectiveType to tune this. Don't guess that "3G is slow" — collect downlink, RTT, and time-to-first-byte per network bucket and adjust your thresholds per region. A 3G connection in Berlin behaves nothing like a 3G connection in rural India.

Network Type Measured Downlink / RTT CDN Adaptation Strategy Typical Improvement We See
Wi-Fi / 4G (ECT: 4g) 8 - 20 Mbps / 20 - 50 ms AVIF/WebP high quality (q85), prefetch next 3 screens, edge TTL 1 hour TTFB ~45 ms, LCP 1.2s, cache hit 92%
3G (ECT: 3g) 0.8 - 2.5 Mbps / 120 - 250 ms WebP q65, max-width 800px, disable video prefetch, SWR 1 day Data transfer -38%, LCP 1.9s vs 3.4s without adaptation
2G / Slow-2G (ECT: 2g) 0.05 - 0.35 Mbps / 400 - 900 ms JPEG q50, max-width 400px, JSON-only API, no prefetch, serve stale 7 days Page weight -68%, timeout errors -55%
Any + Save-Data: on Varies (user preference) Force lite variant regardless of ECT, no autoplay, low-res placeholders first Data savings ~60-70%, bounce rate -22% on slow networks

Putting It Together: A Production-Ready Implementation That Doesn't Fall Over

Look, theory's great, but you need a detection layer that's resilient to the garbage data real devices throw at you. We don't trust a single API in isolation. On our edge, we've learned to fuse three signals: the Network Information API (where available), Client Hints (`Sec-CH-UA`, `ECT`, `RTT`, `Downlink`), and passive server-side throughput measurements from the last 3-5 chunks. If they disagree, we trust our measured throughput. In production, we've seen `navigator.connection.effectiveType` report `4g` while actual measured throughput was 180 Kbps for 12+ seconds on congested cell edges. Blindly trusting that `4g` flag would have pushed 1080p segments and triggered 4-6 seconds of rebuffering.

So we implement a hysteresis loop with debouncing. You don't want to flip CDN profiles every time the radio blips. We use a 5-second debounce window and require two consecutive readings to confirm a downgrade, but only one reading to confirm an upgrade. This asymmetry is intentional — it's far worse to upscale too aggressively and stall than to stay low for an extra 5 seconds. Our switching logic also persists the last known good profile in `sessionStorage` so a page reload on a train doesn't start with a 4K hero image. If you're building Adaptive Quality Delivery for Mobile: Network-Aware Content Optimization, this persistence is non-negotiable — you'll cut your initial rebuffer rate by 30-40% on flaky connections.

// Production-tested network-aware CDN switcher (simplified)
class NetworkAwareCDN {
  constructor() {
    this.currentProfile = sessionStorage.getItem('cdn-profile') || 'high';
    this.lastSwitch = 0;
    this.DEBOUNCE_MS = 5000;
    this.DOWNLINK_THRESHOLD = 1.5; // Mbps
    this.RTT_THRESHOLD = 400; // ms
  }

  init() {
    // 1. Passive measurement from fetch timing
    this.observeFetchTiming();
    
    // 2. Network Information API (Chromium/Android only)
    const conn = navigator.connection;
    if (conn) {
      conn.addEventListener('change', () => this.handleChange(conn));
      this.handleChange(conn); // initial check
    }

    // 3. Listen for online/offline + visibility for mid-session switches
    window.addEventListener('online', () => this.revalidate('online-event'));
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'visible') this.revalidate('foreground');
    });
  }

  handleChange(conn) {
    const now = Date.now();
    if (now - this.lastSwitch < this.DEBOUNCE_MS) return;
    
    const downlink = conn.downlink; // Mbps
    const rtt = conn.rtt; // ms
    const saveData = conn.saveData;

    let nextProfile = 'high';
    if (saveData || downlink < 0.5 || rtt > 800) nextProfile = 'low'; // 2G/3G or Data Saver
    else if (downlink < this.DOWNLINK_THRESHOLD || rtt > this.RTT_THRESHOLD) nextProfile = 'medium';

    if (nextProfile !== this.currentProfile) {
      this.switchProfile(nextProfile);
      this.lastSwitch = now;
    }
  }

  switchProfile(profile) {
    // Tell CDN edge via Client Hint + query param for cache variation
    document.documentElement.dataset.cdnProfile = profile;
    sessionStorage.setItem('cdn-profile', profile);
    // Example: swap image CDN params from w=1200&q=80 to w=640&q=60
    window.dispatchEvent(new CustomEvent('cdn-profile-change', { detail: profile }));
    this.currentProfile = profile;
  }
}
new NetworkAwareCDN().init();

Advanced Patterns and Troubleshooting Flaky Transitions

Once you've got the basics working, you'll hit the edge cases that kill UX in the wild. The biggest one we see: the Wi-Fi to cellular handoff where the IP changes mid-HLS segment fetch. If you're using HTTP/2 or HTTP/3 with connection coalescing, that fetch will hang for 15-30 seconds before the browser aborts, unless you handle it. We've moved to aborting in-flight media requests after 3.5 seconds of stalled throughput and re-issuing them with a `Cache-Control: stale-while-revalidate` bypass to the new interface. Combine that with 0-RTT on QUIC and you'll shave 600-900ms off recovery time. Don't forget to listen for `navigator.connection` flapping — we've seen Samsung devices fire 8-10 `change` events in 4 seconds when walking between access points. Without debouncing, you'll thrash your ABR ladder and destroy your cache hit ratio.

Another pattern that pays off massively is predictive downgrading. Don't wait for the throughput to collapse; watch the signals. If `RTT` spikes from 60ms to 280ms over two consecutive segment downloads while `downlink` stays flat, you're about to lose the cell tower. We proactively drop one quality rung before the stall, rather than after. This is where Mobile CDN Prefetch Strategies: Predicting and Preloading Content Before Users Need It becomes critical — on `high` profiles we prefetch the next 2 segments at 1.2x speed, but on `medium` we prefetch only the next segment's audio and poster, saving ~350KB per page and avoiding wasted bytes that get abandoned on a switch.

Troubleshooting tip: If your analytics show iOS users never switch profiles, it's not because their network is perfect. Safari still doesn't expose `navigator.connection` at all. For iOS, you must fall back to server-side detection via Client Hints (if the user has opted in) and, more reliably, your own throughput beacons. Send a 25KB `204` beacon on page load and measure TTFB + download time. We've found that beacon correlates to actual video throughput with an r=0.82, which is good enough to pick the right initial rendition. And please, test with certificate pinning in mind — if you're doing Mobile CDN Certificate Pinning: Securing App-to-Edge Connections Against Interception, your fallback retry on network switch can't just blindly hit a different edge hostname or you'll trigger a pin failure. Make sure your pinned intermediates cover all your failover edges.

Frequently Asked Questions

Can I just use navigator.connection.effectiveType and call it a day?

No. It's a best-effort hint, not a measurement. On Android it buckets you into `slow-2g`, `2g`, `3g`, `4g` based on a moving average of RTT and downlink, and it lags reality by 5-15 seconds. On iOS it's undefined. We've seen it report `4g` on a throttled 700 Kbps connection for almost 20 seconds. Use it as one vote in your decision, weight your own passive measurements higher, and always respect `navigator.connection.saveData === true` — that's the one signal users explicitly set.

How do I handle a network switch that happens during a large file or video segment download?

Don't wait for the browser timeout. Wrap your fetches with an `AbortController` and monitor throughput per chunk. If you get < 50KB in 3 seconds on what should be a 2 Mbps link, abort and retry on the new connection. For HLS/DASH, configure your player to use a 4-second segment timeout and enable `capLevelToPlayerSize` so it doesn't try to recover at the same high bitrate. On the CDN side, ensure your segments are cacheable per profile — we vary cache by `ECT` and `Downlink` hints to avoid serving a 5 Mbps segment to a newly-downgraded client from cache.

VPNs and private relays break my detection — how do I deal with that?

They do. iCloud Private Relay and most enterprise VPNs add 80-150ms of RTT and mask the underlying radio type, so you'll often see a stable `rtt: 180ms` even when the radio is flapping. The workaround is to ignore `effectiveType` entirely when you detect a relay (look for `via` headers or known relay ASNs) and rely purely on passive throughput. We've also had success with timing the TLS handshake — if handshake time jumps by >200ms without a corresponding downlink change, assume the tunnel is congested and hold your current profile for at least 10 seconds.

What's the best way to test network-aware CDN behavior before it hits production?

Don't just use Chrome DevTools throttling — it only throttles JS, not the actual socket. Use `tc` or `Network Link Conditioner` on a real device, and drive test with `com.apple.networkLinkConditioner` profiles: set 1.6 Mbps / 400ms RTT / 1% loss for a realistic poor 3G. Automate it with WebPageTest's `custom` connectivity or use our favorite trick: run a local QUIC proxy with `quiche` and inject 2% packet loss and 800ms jitter for 10-second bursts. Log every `change` event, beacon timing, and profile switch to analytics so you can see if your 5-second debounce is actually preventing thrash.

Related Articles

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