Progressive Web Apps and CDN Strategy: Offline-First Delivery at the Edge
Progressive Web Apps introduce a caching layer that most CDN architectures were not designed to accommodate. A service worker sits between the browser and the network, intercepting requests and serving responses from its own cache. When a CDN also sits between the browser and the origin, two independent caching systems operate on the same content with different invalidation timelines. Getting this architecture right means understanding where each cache belongs in the request lifecycle and how they complement rather than conflict with each other. This guide covers the practical integration patterns between CDN caching strategies and PWA service worker caching.
The Two-Layer Cache Architecture
A traditional web application has one network cache: the CDN edge. Requests flow from the browser through the CDN to the origin. A PWA adds a second cache that sits inside the browser itself, controlled by a service worker JavaScript file. Understanding how these two layers interact is essential for correct and performant delivery.
The service worker cache operates inside the browser on the user’s device. It persists across page loads and works offline. The CDN cache operates on network edge servers distributed globally. It requires network connectivity but serves content faster than the origin. The key architectural decision is which cache takes priority for each type of content.
Service Worker Caching Strategies for CDN-Served Content
Three primary caching strategies govern how a service worker interacts with CDN-delivered resources. Each strategy makes a different tradeoff between freshness, performance, and offline availability.
| Metric | Edge Processing | Origin Processing | Improvement |
|---|---|---|---|
| Latency (p50) | 8–15 ms | 120–300 ms | 15–20x faster |
| Cache Hit Ratio | 85–95% | N/A | Reduces origin load |
| TLS Handshake | 1–5 ms (nearby PoP) | 50–150 ms | 10–30x faster |
| Bandwidth Cost | $0.01–0.04/GB | $0.05–0.12/GB | 3–5x cheaper |
| Availability | 99.99% (multi-PoP) | 99.9% (single DC) | 10x fewer outages |
Cache-First for Static Assets
Static assets like CSS, JavaScript bundles, images, and fonts change only during deployments. A cache-first strategy checks the service worker cache before making any network request. If the asset exists in the service worker cache, it serves immediately without hitting the CDN. If not cached, the request passes through to the CDN, and the response is stored in the service worker cache for future requests.
// Service worker: cache-first for static assets served by CDN
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Static assets with content hashes — cache first
if (url.pathname.match(/\.(js|css|woff2|svg|png|webp)$/)) {
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
return fetch(event.request).then((response) => {
// Only cache successful responses from CDN
if (response.ok) {
const clone = response.clone();
caches.open('static-v1').then((cache) => {
cache.put(event.request, clone);
});
}
return response;
});
})
);
return;
}
});
This strategy works because static assets served through a CDN use content-hashed filenames. When you deploy a new version, the filename changes, so the service worker fetches the new file from the CDN and caches it. The old file remains cached but is never requested again. This eliminates the invalidation coordination problem entirely — each version is a distinct cache entry.
Network-First for Dynamic Content
Dynamic content like API responses, article pages, and user-specific data should use a network-first strategy. The service worker attempts a network fetch through the CDN first. If the network is available and the CDN responds, that fresh response is served and cached as a fallback. If the network fails, the service worker falls back to whatever it previously cached.
This strategy ensures users always see the latest content when online while maintaining offline access to previously viewed pages. The CDN’s short-TTL caching for dynamic content works naturally with this approach because the service worker always tries the network first. Learn more about configuring TTLs for dynamic content in TTL Optimization: Balancing Freshness and Performance.
Stale-While-Revalidate for Semi-Dynamic Content
Content that updates periodically but does not require immediate freshness benefits from stale-while-revalidate at the service worker level. This mirrors the HTTP stale-while-revalidate cache directive but operates inside the browser. The service worker serves the cached version immediately for instant display, then makes a background network request through the CDN to update the cache for the next visit.
This strategy produces the fastest perceived performance because the user never waits for a network request. The content might be slightly stale on the current page load, but it will be fresh on the next one. This works well for user profile data, recommendation feeds, and content listings that change but are not time-critical.
The App Shell Model with CDN Integration
The app shell architecture separates a PWA into two components: a lightweight shell (navigation bar, layout grid, loading states) and the dynamic content that fills it. The CDN plays different roles for each component.
The app shell HTML is a small file, typically 5 to 15 kilobytes, that contains the page structure without any content. It loads instantly because the service worker serves it from its cache on every navigation. The CDN caches this file with a long TTL (one week or more) because it changes only during deployments. When you deploy a new shell, you update the service worker which fetches the new shell from the CDN during its installation phase.
// Service worker: precache the app shell during installation
const SHELL_VERSION = 'shell-v42';
const SHELL_URLS = [
'/',
'/shell.html',
'/assets/app.css',
'/assets/app.js',
'/assets/icons/icon-192.png',
'/offline.html',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(SHELL_VERSION).then((cache) => {
// All these URLs are served by the CDN
return cache.addAll(SHELL_URLS);
})
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys
.filter((key) => key.startsWith('shell-') && key !== SHELL_VERSION)
.map((key) => caches.delete(key))
);
})
);
});
Dynamic content loads asynchronously after the shell renders. API responses that populate the content area are fetched through the CDN with short TTLs. The user sees the app shell immediately (from service worker cache) and then watches content populate (from CDN or service worker fallback). This separation means the Largest Contentful Paint metric reflects the shell rendering speed rather than the content fetch speed, producing consistently good Core Web Vitals scores.
Service Worker Update Lifecycle and CDN Coordination
The service worker file itself (sw.js) requires special CDN caching treatment. Browsers check for service worker updates by re-fetching sw.js on every navigation, comparing the response byte-for-byte with the installed version. If the CDN caches sw.js with a long TTL, users will not receive service worker updates promptly.
The correct CDN configuration for sw.js is Cache-Control: no-cache or max-age=0, must-revalidate. This forces the CDN to revalidate with the origin on every request using conditional GET (If-None-Match / If-Modified-Since). When the file has not changed, the origin responds with 304 Not Modified and the CDN serves its cached copy. When the file has changed, the origin sends the new version. This approach eliminates stale service worker delivery while still leveraging CDN bandwidth for the actual file transfer.
Precache Manifest Generation at the Edge
Modern PWA build tools like Workbox generate a precache manifest — a list of URLs and revision hashes that the service worker should cache during installation. In a CDN-served architecture, these URLs must reference the CDN-served paths, not origin paths.
// workbox-config.js — configured for CDN-served assets
module.exports = {
globDirectory: 'dist/',
globPatterns: [
'**/*.{html,css,js,woff2,svg,png,webp}',
],
swDest: 'dist/sw.js',
// CDN prefix for all precached URLs
modifyURLPrefix: {
'': 'https://cdn.example.com/app/v42/',
},
// Skip files larger than 2MB to avoid slow SW installation
maximumFileSizeToCacheInBytes: 2 * 1024 * 1024,
runtimeCaching: [
{
urlPattern: /\/api\//,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 24 * 60 * 60,
},
networkTimeoutSeconds: 5,
},
},
],
};
An advanced pattern uses edge functions to generate the precache manifest dynamically. Instead of baking CDN URLs into the build, an edge function intercepts requests for the manifest and constructs it based on the current deployment version. This enables instant manifest updates without rebuilding the service worker itself. The edge function reads the deployment manifest from a key-value store and returns the correct list of URLs with their content hashes.
Offline Fallback Architecture
The offline fallback is the page users see when they navigate to a URL they have not previously visited while offline. Implementing this correctly with a CDN requires coordinating the initial cache population (which must happen while online) with the fallback serving logic (which activates when offline).
During service worker installation, the offline fallback page is fetched from the CDN and cached. This is a one-time cost that happens transparently during the user’s first visit. The fallback page should be self-contained: all CSS inlined, any images embedded as data URIs, no external dependencies. This ensures it renders correctly regardless of what other resources are cached.
// Offline fallback — serves cached page when navigation fails
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.then((response) => {
// Cache successful navigation responses for offline use
const clone = response.clone();
caches.open('pages-v1').then((cache) => {
cache.put(event.request, clone);
});
return response;
})
.catch(() => {
// Try cached version of the exact URL first
return caches.match(event.request).then((cached) => {
if (cached) return cached;
// Fall back to the generic offline page
return caches.match('/offline.html');
});
})
);
return;
}
});
The CDN should serve the offline fallback page with cache headers that enable long-term browser caching: Cache-Control: public, max-age=604800, immutable. Since the page filename includes a content hash, updates happen through filename changes rather than cache invalidation. This approach works naturally with both CDN caching and service worker caching. More details on cache header strategies are available in Cache-Control Headers: A Complete Reference.
Web App Manifest and CDN Delivery
The web app manifest (manifest.json) defines how the PWA appears when installed: name, icons, theme color, start URL, and display mode. The CDN caches this file like any other static asset, but it requires attention because browsers re-fetch it periodically to check for changes to the installed app’s metadata.
Set the CDN TTL for manifest.json to 24 hours. This balances update propagation speed with cache efficiency. When you change the manifest (updating the app name, adding icons for a new platform, changing the theme color), purge the CDN cache for this file specifically. Browsers that have installed the PWA will pick up the changes within 24 hours of the CDN purge.
For multi-regional deployments, an edge function can customize the manifest per geographic region. The function reads the user’s location from CDN headers, selects the appropriate localized app name and icon set, and returns a region-specific manifest. Use Vary: Accept-Language as the cache key so each locale gets its own cached manifest version at each edge node.
Performance Monitoring for PWA and CDN
Monitoring a PWA served through a CDN requires tracking metrics at both cache layers. Standard CDN analytics capture cache hit ratios, TTFB, and bandwidth. Service worker analytics require instrumentation inside the service worker code itself.
Key metrics to track include service worker cache hit ratio (percentage of requests served from service worker cache versus CDN), precache success rate (whether all shell assets cached during installation), time to interactive from service worker cache versus CDN cache, and offline session frequency (how often users access the app while offline). For broader monitoring implementation, see Real User Monitoring (RUM) for CDN.
Compare the service worker cache hit ratio against the CDN cache hit ratio to identify optimization opportunities. If the service worker serves 80 percent of requests from its cache, the CDN handles only the remaining 20 percent of navigation requests and uncached API calls. This means CDN costs decrease as the PWA matures and more users have populated service worker caches. The CDN remains critical for first-visit performance and service worker installation speed, but ongoing traffic shifts increasingly to the browser-side cache.