Add Offline Support to WordPress with Service Workers and Workbox

A Service Worker is a JavaScript file that runs in a background browser thread, independent of the page, and intercepts network requests — allowing a WordPress site to serve cached content when the user is offline, cache critical assets on first visit, and provide an installable Progressive Web App (PWA) experience. The Service Worker lifecycle has three phases: registration (the main page calls navigator.serviceWorker.register('/sw.js')), installation (the worker caches a precache manifest of URLs during the install event), and activation (the new worker takes control and removes old caches in the activate event). Workbox — Google’s service worker library — provides pre-built caching strategy classes that replace manual cache.match() / fetch() / cache.put() boilerplate: CacheFirst serves from cache and falls back to network (ideal for images and fonts), NetworkFirst tries network and falls back to cache (ideal for HTML pages and API responses), and StaleWhileRevalidate serves from cache immediately while fetching a fresh version in the background (ideal for CSS and JS that change infrequently). For WordPress, the Service Worker must be served from the domain root (/sw.js) to control the entire origin — a file at /wp-content/themes/mytheme/sw.js would only control pages under /wp-content/themes/mytheme/. WordPress’s enqueue system does not know about the service worker registration script, so it must be output via a wp_head or wp_footer inline script. An offline fallback page registered in the precache manifest is served when the user navigates to a URL not in cache and the network is unavailable — it should be a minimal HTML page explaining the offline state. The Web App Manifest (manifest.json) linked in <head> enables the browser “Add to Home Screen” prompt and defines the app name, icons, theme color, and display mode. The Redis caching post reduces server-side TTFB; the Service Worker cache layer reduces client-side repeat-visit load times to near zero for cached resources.

Problem: A WordPress site for a conference event must remain usable offline — attendees need to access the schedule, speaker list, and venue map after entering the venue where mobile data coverage is unreliable.

Solution: Register a Workbox-powered Service Worker that precaches critical pages and assets on installation, uses NetworkFirst for HTML and CacheFirst for images and fonts, and serves a custom offline fallback page for uncached navigation requests.

// /sw.js — served from WordPress root (copy via build script or wp_filesystem)
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute, NavigationRoute }           from 'workbox-routing';
import { NetworkFirst, CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin }  from 'workbox-expiration';

// Precache: injected by workbox-webpack-plugin / workbox-build at build time
// In development, list the critical URLs manually:
precacheAndRoute([
    { url: '/',              revision: 'v1' },
    { url: '/schedule/',     revision: 'v1' },
    { url: '/speakers/',     revision: 'v1' },
    { url: '/offline/',      revision: 'v1' },  // offline fallback page
    { url: '/wp-content/themes/mytheme/style.min.css', revision: 'v1' },
]);

// HTML pages: Network first (fresh content), fall back to cache
registerRoute(
    ({ request }) => request.mode === 'navigate',
    new NetworkFirst({
        cacheName: 'pages',
        networkTimeoutSeconds: 3,
        plugins: [ new ExpirationPlugin({ maxEntries: 50 }) ],
    })
);

// Images: Cache first, expire after 30 days
registerRoute(
    ({ request }) => request.destination === 'image',
    new CacheFirst({
        cacheName: 'images',
        plugins: [ new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }) ],
    })
);

// CSS/JS: Stale-while-revalidate
registerRoute(
    ({ request }) => request.destination === 'script' || request.destination === 'style',
    new StaleWhileRevalidate({ cacheName: 'assets' })
);

// Offline fallback for uncached navigation
const handler = createHandlerBoundToURL('/offline/');
const navRoute = new NavigationRoute(handler);
registerRoute(navRoute);

// Register SW and link Web App Manifest in WordPress
add_action('wp_head', function() {
    echo '' . PHP_EOL;
});

add_action('wp_footer', function() {
    echo '' . PHP_EOL;
});

// Serve manifest.json dynamically
add_action('init', function() {
    if (!isset($_SERVER['REQUEST_URI'])) return;
    if (trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/') !== 'manifest.json') return;

    header('Content-Type: application/manifest+json');
    echo wp_json_encode([
        'name'             => get_bloginfo('name'),
        'short_name'       => substr(get_bloginfo('name'), 0, 12),
        'start_url'        => home_url('/'),
        'display'          => 'standalone',
        'background_color' => '#ffffff',
        'theme_color'      => '#2563eb',
        'icons'            => [
            ['src' => get_theme_file_uri('images/icon-192.png'), 'sizes' => '192x192', 'type' => 'image/png'],
            ['src' => get_theme_file_uri('images/icon-512.png'), 'sizes' => '512x512', 'type' => 'image/png'],
        ],
    ], JSON_UNESCAPED_SLASHES);
    exit;
});

NOTE: Service Workers require HTTPS — they will not register over plain HTTP (localhost is the only exception for development). Ensure your WordPress site has a valid SSL certificate and that all asset URLs in the precache manifest use HTTPS; a single HTTP URL in the manifest will cause the entire precache to fail with a mixed-content error in the browser console.