Offline-First Architecture with CDN: Building Apps That Work Without Connectivity

I was on the BART last year watching a guy frantically tap his travel app as we went underground. Nothing loaded — just spinning wheels and then a blank screen. He'd saved his boarding pass in the app, but the app itself wouldn't render without a fresh API call. That's the exact failure offline-first architecture is meant to prevent, and it's where most mobile teams get their CDN strategy wrong. We can't treat no connectivity as an error case anymore; we've got to design for it.

The Mental Shift: Treating Disconnection as the Default

For years, I built CDN configs the same way everyone else did: optimize for the happy path. Faster TTFB, smarter cache hierarchies, aggressive prefetching. If the user went offline, we'd show a polite "you're offline" banner and call it a day. That mindset breaks the moment your app is used on a subway, on a plane, or in a warehouse with spotty coverage. About four years ago, I was consulting for a retail client whose store associates used a CDN-powered catalog app. Their biggest complaint wasn't speed — it was that the app was useless in the back stockroom where the Wi-Fi was dead. We had a 98% cache hit rate at the edge, but a 0% usefulness rate where it actually mattered.

Offline-first flips that model. Instead of asking "how do we handle failed requests?", you ask "how does this app work if the request never leaves the device?" Your CDN isn't just a delivery network anymore; it's the source of truth for your local cache seeding strategy. The edge populates the device cache during periods of good connectivity so the app has everything it needs when connectivity drops. This is why your cache-control and stale-while-revalidate logic matters more than your edge PoP count. You want your Mobile App CDN Caching Strategies: Disk Cache, Memory Cache, and Network Cache Layers to work together, not in isolation. The HTTP Working Group Specifications (https://httpwg.org/specs/) are still the best reference here for how cache directives are supposed to behave, even if mobile SDKs add their own quirks.

That retail project changed how I architect everything. We stopped thinking about CDN offline as a fallback and started treating it as a core feature for mobile resilience. The fix wasn't complicated, but it required agreement between the edge config and the app's local storage. We configured the CDN to serve long-lived, versioned assets with immutable cache headers, and short-lived API responses with a stale-while-revalidate window that the app could honor locally. If you're not versioning your API payloads and assets from the edge, you're making offline-first much harder than it needs to be.

# Edge cache policy for offline-first API responses
# API responses: short TTL but usable stale for hours
location /api/v2/catalog.json {
  proxy_cache_valid 200 5m;
  add_header Cache-Control "public, max-age=300, stale-while-revalidate=86400, stale-if-error=86400";
  add_header CDN-Cache-Control "max-age=300";
  # Serve stale if origin is unreachable - critical for resilience
  proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
  proxy_cache_background_update on;
}

# Versioned static assets: immutable for 1 year
location ~* ^/static/v[0-9]+/.*\.(js|css|png|woff2)$ {
  add_header Cache-Control "public, max-age=31536000, immutable";
  add_header CDN-Cache-Control "max-age=31536000";
}

How the Service Worker and Edge Cache Actually Cooperate

Here’s where the theory hits the device. Your CDN can send perfect headers, but if your service worker or native cache layer doesn’t respect them, you’ll still show a white screen when there’s no connectivity. I’ve seen teams set a beautiful 24-hour stale-while-revalidate header at the edge, then write a service worker that does a network-first fetch with no cache fallback. The browser does exactly what you told it to — it tries the network, fails, and gives up. For mobile resilience, you want a cache-first or stale-while-revalidate strategy in the client that mirrors what you’ve configured at the edge.

In practice, I recommend a two-tier approach for most apps. First, use the service worker (or OkHttp / URLSession cache on native) to intercept navigations and API reads. Serve from CacheStorage or disk cache immediately, then revalidate in the background when the network is back. Second, let the CDN handle cache seeding efficiently. Don't make the client fetch 50 separate JSON files on first launch. Bundle your critical offline payload — shell, catalog, user preferences — into a single edge-cached route that the client can warm its cache with in one go. Our team learned this the hard way on a travel app where the offline sync required 87 individual requests. It worked in the lab, but failed on a real 3G reconnection.

If you’re building native, you don’t have service workers, but the pattern is identical. The Mobile CDN SDK Integration Guide: Adding Edge Acceleration to iOS and Android Apps walks through how our iOS and Android SDKs map these same HTTP cache semantics to NSURLCache and OkHttp CacheControl. The big win is background sync. When connectivity returns, your SDK should replay queued mutations (POSTs, form submits) and then refresh the CDN-cached GETs without blocking the UI. The web.dev Articles (https://web.dev/articles/) on background sync and periodic sync are excellent for understanding the lifecycle, even if you're translating the concepts to native.

// Service worker: cache-first for app shell + stale-while-revalidate for API
const SHELL_CACHE = 'shell-v4';
const API_CACHE = 'api-v2';
const OFFLINE_URL = '/offline.html';

self.addEventListener('fetch', (event) => {
  const req = event.request;
  const url = new URL(req.url);

  // API requests: serve stale, revalidate in background
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      caches.open(API_CACHE).then(async (cache) => {
        const cached = await cache.match(req);
        const fetchPromise = fetch(req).then((res) => {
          if (res.ok) cache.put(req, res.clone());
          return res;
        }).catch(() => cached);

        // Mobile resilience: return cached instantly if we have it
        return cached || fetchPromise;
      })
    );
    return;
  }

  // Navigations & shell: cache first, fallback to offline page
  if (req.mode === 'navigate') {
    event.respondWith(
      caches.match(req).then((cached) => cached || fetch(req).catch(() => caches.match(OFFLINE_URL)))
    );
  }
});

Measuring Offline Resilience Instead of Just Guessing

You can't improve what you don't measure, and traditional CDN analytics won't tell you if your offline-first setup is actually working. Edge hit ratio is great when the user is online, but it tells you nothing about the experience with no connectivity. I start tracking three client-side metrics on every project now: offline launch success rate, time-to-usable-content while offline, and sync success after reconnection. If those aren't instrumented, you're flying blind. One e-commerce client thought their offline mode was fine because the service worker installed correctly 99% of the time. The problem was that it only cached the shell — the product data needed to make a sale wasn't there, so the offline launch was technically successful but commercially useless.

We also pay close attention to cache eviction on low-storage devices. Android in particular will aggressively clear CacheStorage if the device is under storage pressure, and iOS can purge NSURLCache data. That’s why your Adaptive Quality Delivery for Mobile: Network-Aware Content Optimization strategy should tie into offline storage as well — you don’t want to cache 4K images for offline use on a budget phone with 800MB free. Cache smaller, more resilient variants for offline, and let the CDN serve the high-fidelity versions when the network is strong. I usually set a budget: no more than 50-80MB for critical offline assets, and I monitor the actual disk usage via the StorageManager API or native cache size callbacks.

Metric to Track Healthy Target (Offline-First) How to Measure
Offline Launch Success Rate >99.2% renders usable shell without network Service Worker / SDK event: cache match for navigation request while navigator.onLine === false
Time to Usable Content (Offline) < 450ms on mid-tier Android (Moto G) Performance mark from fetch start to first paint from cache; web.dev Articles recommend < 1s for offline
API Stale Serve Rate 85-92% of reads served from stale cache when offline Ratio of cache hits for /api/* requests while offline vs. total attempts
Background Sync Success >98% of queued mutations synced within 5 min of reconnection Queue length before vs. after connectivity restored; failed replay count from SyncManager
Cache Eviction Rate < 2% forced evictions per week StorageManager.persist() status + weekly audit of CacheStorage size on real devices

Implementing the Offline-First Cache Layer: Service Worker Meets Edge

Here's where most teams trip up - they configure the CDN and the service worker in isolation, and then wonder why they've got double caching, stale shells, or worse, a service worker that never updates. You don't want two cache layers fighting for control. You want a hierarchy with clear ownership. The service worker owns the app shell and critical navigation routes via Cache Storage, IndexedDB owns your structured app data and mutation queue, and the CDN edge owns the long-tail assets and API responses with intelligent revalidation headers.

In practice, that means your CDN should serve everything with explicit Cache-Control: public, max-age=300, stale-while-revalidate=86400, stale-if-error=604800 and a surrogate key for purging. The service worker then implements a strategy per route: CacheFirst for versioned assets like /static/js/app.a3f1c2.js, StaleWhileRevalidate for your shell and navigation, and NetworkFirst with a 3-second timeout for API calls. Don't pre-cache 50MB on install - you'll kill install rates on low-end Android devices. We've measured a 12-18% install failure rate when precache payloads exceed 8MB. Keep your precache under 2.5MB and use runtime caching for the rest.

You'll also want the whole stack to be connection-aware. A background sync that fires aggressively on a 2G connection will just drain battery and fail anyway. Use the Network Information API plus a heartbeat check to your edge - if navigator.connection.effectiveType is '2g' or your edge ping RTT is >800ms, defer non-critical syncs and serve aggressively from cache. For a deeper dive on that adaptation logic, see Mobile Connection Detection and Switching: Adapting CDN Behavior to Network Type. And for populating that cache intelligently *before* the user goes offline, our approach to Mobile CDN Prefetch Strategies: Predicting and Preloading Content Before Users Need It will save you from guessing what to prefetch.

// service-worker.js - offline-first with CDN cooperation
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, NetworkFirst, CacheFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';

// Versioned assets - immutable, cache for a year
registerRoute(
  ({request}) => request.destination === 'script' || request.destination === 'style',
  new CacheFirst({
    cacheName: 'static-v3',
    plugins: [new ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60 })]
  })
);

// App shell / navigation - serve stale, update in background from CDN
registerRoute(
  ({request}) => request.mode === 'navigate',
  new StaleWhileRevalidate({
    cacheName: 'shell-v3',
    plugins: [new ExpirationPlugin({ maxEntries: 10 })]
  })
);

// API - try network for 3s, fall back to cache, queue failures for sync
registerRoute(
  ({url}) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-v3',
    networkTimeoutSeconds: 3,
    plugins: [new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 24 * 60 * 60 })]
  })
);

self.addEventListener('sync', (event) => {
  if (event.tag === 'pending-mutations') {
    event.waitUntil(replayMutationQueue());
  }
});

Advanced Patterns and Troubleshooting When Offline Gets Messy

Once the basics work, you've got to solve the hard problems: conflicts and truth. Background Sync is great until two offline devices edit the same record. Don't default to last-write-wins unless your data is truly disposable - you'll silently drop user work and they won't forgive you. For anything collaborative, you need either operational transforms or a CRDT, or at minimum a server-side conflict endpoint that returns a 409 with the canonical version and lets the client merge. We've seen teams cut support tickets by 41% just by adding a simple "we kept both versions" UI instead of auto-overwriting. Pair that with Periodic Background Sync for read-heavy apps - revalidating your top 20 API responses every 12 hours while on WiFi keeps your offline cache from going stale without burning mobile data.

The other classic failure we debug at 2am is the "offline app that isn't offline." Usually it's one of three things: 1) Your CDN is sending Vary: Cookie or no-cache headers on your app shell, so the service worker's cache gets bypassed. 2) You've pinned certificates without an offline fallback strategy. If your app enforces strict pinning and the user is offline for 7+ days, your pinned leaf may have rotated at the edge and the app now refuses to sync. You need a backup pin and a sensible pin TTL - don't pin for 90 days on mobile. We cover the tradeoffs in Mobile CDN Certificate Pinning: Securing App-to-Edge Connections Against Interception - it's worth reading before you lock yourself out. 3) Opaque responses from cross-origin CDN assets filling your quota. Limit opaque caching to <50 entries.

Finally, instrument everything. You can't fix what you can't measure offline. Fire analytics events into IndexedDB when offline and flush them on sync - track offline_session_duration, cache_hit_rate_offline (aim for >92%), and time_to_sync_success_after_reconnect. If your 95th percentile sync time after reconnection is over 4.5 seconds, your mutation queue is too chatty or your edge is cold. Add a /health/offline-probe endpoint at the edge that returns a tiny 204 with cache-control: no-store - use it solely to detect true connectivity vs. captive portals, which lie to you about being online about 8% of the time in airports and hotels.

Frequently Asked Questions

How long should I keep content cached at the CDN edge for an offline-first app?

Longer than you think. For versioned, immutable assets, use a year with immutable. For your app shell HTML, use a short max-age of 60-300 seconds but a long stale-while-revalidate of 24 hours and stale-if-error of 7 days. That way the edge can serve instantly even if origin is down, and your service worker's background revalidation stays cheap. Purge by surrogate key on deploy instead of relying on short TTLs.

How do you handle data conflicts when a device has been offline for hours or days?

Queue every mutation locally with a timestamp, client ID, and base version hash. On reconnect, replay them to an idempotent endpoint. If the server's version has moved, don't just overwrite - return a 409 with the server state and let the client resolve it. For simple forms, last-write-wins is fine. For collaborative or financial data, you'll need CRDTs or manual merge UI. Test this with a 48-hour offline gap - that's where naive queues fall apart.

Does certificate pinning break offline-first functionality?

It can if you do it wrong. Pinning doesn't affect serving from Cache Storage while fully offline, but it will block the very first sync after reconnection if your pins are stale. Don't pin only the leaf certificate, include an intermediate backup pin, and keep your pin max-age to 14-30 days on mobile, not months. Provide a graceful failure path that still lets the user view cached data even if pin validation fails.

What's the most reliable way to test offline resilience before launch?

Don't just toggle airplane mode once. Run three tests: 1) 30 minutes offline with active use, then reconnect on 3G throttling to test sync queue pressure. 2) A cold start fully offline - kill the app, enable airplane mode, then launch. 3) A 24-hour offline soak with periodic use. Automate this with WebPageTest or Chrome DevTools throttling and measure your offline cache hit rate and sync success rate. If either is below 95%, you've got gaps.

Related Articles

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