Traditional Single-Page Applications (SPAs) suffer from a critical architectural vulnerability: they are completely dependent on uninterrupted network connectivity. Stepping into an elevator, walking through an underground subway transit, working in shielded logistics warehouses, or enduring unstable 3G connections ("Lie-Fi") leaves end users stranded with blank white screens, persistent loading spinners, failed HTTP requests, and the loss of unsaved form data.
The Progressive Web Apps (PWA) architecture transitions web applications into an Offline-First paradigm. In this model, local device storage and caches are treated as the primary source of truth, while remote network connections serve as an asynchronous synchronization channel.
At the core of the PWA runtime lies the Service Worker. It intercepts outbound HTTP traffic, serves pre-cached application shells in constant O(1) time, and guarantees reliable mutation execution using the Background Sync API.
A Service Worker runs in an isolated browser thread without blocking the Main Thread, lacks direct DOM access, and continues operating even after all browser tabs for the origin are closed. By intercepting the browser networking stack, it enforces deterministic caching strategies (Cache-First, Stale-While-Revalidate, Network-First), delivering instant UI initialization (Time to Interactive < 50 ms) regardless of physical network availability.
Conceptually, a Service Worker acts as a local edge proxy server (Local Reverse Proxy / Gateway) running directly on the user's client hardware between the React application JavaScript and the physical network card:
┌──────────────────────────────────────────────────────────────┐
│ REACT 19 SPA (Main Thread) │
│ fetch('/api/catalog') │ fetch('/assets/bundle.js') │
└──────────────────────────────┬───────────────────────────────┘
│ Intercept Network Stack
▼ (self.onfetch event)
┌──────────────────────────────────────────────────────────────┐
│ SERVICE WORKER (Programmable Network Proxy) │
│ │
│ ┌─────────────────────────┐ ┌────────────────────────┐ │
│ │ Workbox Router │ │ Background Sync Queue │ │
│ │ - Cache-First (Static) │ │ (Deferred mutations in │ │
│ │ - Stale-While-Rev (API)│ │ IndexedDB) │ │
│ └────────────┬────────────┘ └───────────┬────────────┘ │
└───────────────┼──────────────────────────────┼───────────────┘
│ │
┌───────┴────────┐ │
▼ ▼ ▼
┌───────────────┐ ┌──────────────┐ ┌──────────────┐
│ Cache Storage │ │ Internet │ │ Remote API │
│ (Disk: 0–5 ms)│ │ (Remote CDN) │ │ (When Online)│
└───────────────┘ └──────────────┘ └──────────────┘Unlike the standard browser HTTP cache governed exclusively by server-side Cache-Control headers, Cache Storage is fully controlled by Service Worker code.
A Service Worker is governed by a strict state machine:
scope).install event fires. Here, the minimal application shell (App Shell: HTML, critical JS/CSS bundles, fonts) is fetched and stored in Cache Storage.activate event fires. The worker performs cache rotation: purging outdated cache keys from previous deployments and claiming active client tabs.fetch, network recovery triggers sync, and server notifications trigger push.React client invokes navigator.serviceWorker.register('/sw.js'). The browser downloads the script and computes its execution scope.
The self.oninstall event caches the App Shell. Calling self.skipWaiting() forces the new worker to skip the waiting state.
The self.onactivate event deletes obsolete cache keys (v1 -> v2). Calling self.clients.claim() immediately controls open tabs.
self.onfetch routes network traffic across Workbox strategies, while self.onsync flushes queued offline mutations upon reconnection.
A new version of an app is deployed to production, updating sw.js. A user currently has three open tabs. What happens to the new Service Worker if self.skipWaiting() is NOT called in the install event?
Warehouse pickers and delivery personnel operate in reinforced concrete hubs, underground tunnels, and freight elevators where cellular reception drops continuously. Barcodes and shipping receipts are saved locally to IndexedDB, and the Service Worker uses the Background Sync API to transmit mutations as soon as a Wi-Fi link is detected.
On slow mobile data (3G / flaky 4G), each second of page latency degrades conversion rates by 7–10%. Pre-caching critical CSS, brand fonts, and product catalogue templates for instant 0–5 ms delivery from Cache Storage creates a native app feel, while shopping cart states persist across network drops.
Commuters on airplanes and subways can pre-download articles, podcasts, and audio tracks directly into Cache Storage. The application listens to connection state via reactive hooks and dynamically presents an offline collection view.
Internal portals leveraging the Stale-While-Revalidate caching strategy for static bundles and lookup tables launch immediately even when routing through high-latency enterprise VPN gateways (> 300 ms RTT).
Google Workbox is the industry standard for declaring declarative caching and routing policies. Five primary offline strategies exist:
1. Cache-First (Fastest):
Request ──► [Cache Storage] ──(Hit)──► Return (0-5ms)
│
(Miss)
▼
[Network] ──► Save to Cache ──► Return
2. Stale-While-Revalidate (Balanced):
Request ──► [Cache Storage] ──(Hit)──► Return immediately
│
(Parallel background fetch)
▼
[Network] ──► Update Cache in background
3. Network-First (Freshness with Fallback):
Request ──► [Network (timeout: 3s)] ──(Success)──► Update Cache ──► Return
│
(Timeout / Offline)
▼
[Cache Storage] ──(Hit)──► Return stale dataEvery outbound network request (CSS, JS, fetch API) is captured by self.onfetch. Request method and URL are inspected.
Static CDN assets route to Cache-First; API endpoints (/api/*) route to Stale-While-Revalidate with ExpirationPlugin.
When network fails and cache misses occur for navigation, pre-rendered /offline.html is returned.
Failed POST/PUT mutations are captured by BackgroundSyncPlugin and staged into IndexedDB until connectivity returns.
You are developing an e-commerce PWA. Product catalog cards must render instantly upon launch (0–5 ms), but pricing and inventory levels must stay refreshed in the background whenever network is available. Which Workbox strategy is the correct choice?
Here is a comprehensive enterprise PWA infrastructure for React 19:
public/sw.js: Production Service Worker using Workbox, caching strategies, Background Sync, and SKIP_WAITING handlers.src/hooks/useServiceWorkerUpdate.ts: Reactive hook managing Service Worker lifecycle and controller changes.src/hooks/useOnlineStatus.ts: Hydration-safe network status hook powered by useSyncExternalStore.src/components/pwa/PwaOfflineManager.tsx: UI component displaying installation prompts, offline alerts, and update banners.// Service Worker: Fullstack Agentic Lab Enterprise PWA Core v2.4.0
// Uses official Google Workbox CDN
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');
if (workbox) {
console.log('[SW]: Workbox successfully initialized.');
// Immediately take control of clients
workbox.core.clientsClaim();
// 1. Pre-cache critical App Shell
const PRECACHE_ASSETS = [
{ url: '/', revision: '2026-v2-core' },
{ url: '/manifest.webmanifest', revision: '2026-v2-manifest' },
{ url: '/offline.html', revision: '2026-v2-offline-page' },
];
workbox.precaching.precacheAndRoute(PRECACHE_ASSETS);
// 2. Cache-First strategy for static assets: fonts, styles, scripts, images
workbox.routing.registerRoute(
({ request }) =>
request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'font' ||
request.destination === 'image',
new workbox.strategies.CacheFirst({
cacheName: 'agenticlab-static-assets-v2',
plugins: [
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200],
}),
new workbox.expiration.ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
purgeOnQuotaError: true,
}),
],
})
);
// 3. Stale-While-Revalidate for GET API requests
workbox.routing.registerRoute(
({ url, request }) => url.pathname.startsWith('/api/') && request.method === 'GET',
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'agenticlab-api-cache-v2',
plugins: [
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [200],
}),
new workbox.expiration.ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 24 * 60 * 60, // 24 hours
}),
],
})
);
// 4. Network-First strategy with fast timeout for HTML navigation
const navigationHandler = new workbox.strategies.NetworkFirst({
cacheName: 'agenticlab-pages-cache-v2',
networkTimeoutSeconds: 3,
plugins: [
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [200],
}),
],
});
workbox.routing.registerRoute(({ request }) => request.mode === 'navigate', async (params) => {
try {
return await navigationHandler.handle(params);
} catch {
// Fallback to pre-cached offline page on failure
return (await caches.match('/offline.html')) || Response.error();
}
});
// 5. Background Sync: guaranteed queued mutations when online
const bgSyncPlugin = new workbox.backgroundSync.BackgroundSyncPlugin('offline-mutations-queue', {
maxRetentionTime: 24 * 60, // Retain for 24 hours (minutes)
});
workbox.routing.registerRoute(
({ url, request }) => url.pathname.startsWith('/api/mutations/') && request.method === 'POST',
new workbox.strategies.NetworkOnly({
plugins: [bgSyncPlugin],
}),
'POST'
);
// 6. IPC message listener for instant updates
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
console.log('[SW]: Received SKIP_WAITING command. Activating new worker...');
self.skipWaiting();
}
});
} else {
console.error('[SW]: Failed to load Workbox scripts.');
}'use client';
import * as React from 'react';
/**
* Subscribe to native browser online and offline events
*/
function subscribe(callback: () => void): () => void {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function getSnapshot(): boolean {
return navigator.onLine;
}
function getServerSnapshot(): boolean {
// Always assume online on the server to prevent hydration mismatch
return true;
}
/**
* Hook to track live internet connectivity status
* @returns boolean: true if online, false if offline
*/
export function useOnlineStatus(): boolean {
return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}'use client';
import * as React from 'react';
interface ServiceWorkerUpdateState {
isUpdateAvailable: boolean;
registration: ServiceWorkerRegistration | null;
updateApp: () => void;
}
/**
* Hook managing Service Worker lifecycle and updates
*/
export function useServiceWorkerUpdate(): ServiceWorkerUpdateState {
const [isUpdateAvailable, setIsUpdateAvailable] = React.useState(false);
const [registration, setRegistration] = React.useState<ServiceWorkerRegistration | null>(null);
React.useEffect(() => {
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
return;
}
let refreshing = false;
// Reload page once new controller takes over
const handleControllerChange = () => {
if (!refreshing) {
refreshing = true;
window.location.reload();
}
};
navigator.serviceWorker.addEventListener('controllerchange', handleControllerChange);
navigator.serviceWorker
.register('/sw.js')
.then((reg) => {
setRegistration(reg);
// Check if an updated worker is already waiting
if (reg.waiting) {
setIsUpdateAvailable(true);
}
// Listen for new worker installation
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
// New worker is installed and waiting for activation
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
setIsUpdateAvailable(true);
}
});
});
})
.catch((err) => {
console.error('[SW Registration Error]:', err);
});
return () => {
navigator.serviceWorker.removeEventListener('controllerchange', handleControllerChange);
};
}, []);
const updateApp = React.useCallback(() => {
if (registration?.waiting) {
// Send SKIP_WAITING signal to waiting worker
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
}, [registration]);
return { isUpdateAvailable, registration, updateApp };
}'use client';
import * as React from 'react';
import { useOnlineStatus } from '@/hooks/useOnlineStatus';
import { useServiceWorkerUpdate } from '@/hooks/useServiceWorkerUpdate';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function PwaOfflineManager() {
const isOnline = useOnlineStatus();
const { isUpdateAvailable, updateApp } = useServiceWorkerUpdate();
const [installPrompt, setInstallPrompt] = React.useState<BeforeInstallPromptEvent | null>(null);
const [isStandalone, setIsStandalone] = React.useState(false);
React.useEffect(() => {
// Detect if already running in standalone PWA mode
const checkStandalone =
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as unknown as { standalone?: boolean }).standalone === true;
setIsStandalone(checkStandalone);
// Intercept native browser install prompt
const handleBeforeInstall = (e: Event) => {
e.preventDefault();
setInstallPrompt(e as BeforeInstallPromptEvent);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstall);
window.addEventListener('appinstalled', () => {
setIsStandalone(true);
setInstallPrompt(null);
});
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
};
}, []);
const handleInstallClick = async () => {
if (!installPrompt) return;
await installPrompt.prompt();
const { outcome } = await installPrompt.userChoice;
if (outcome === 'accepted') {
setIsStandalone(true);
}
setInstallPrompt(null);
};
return (
<div aria-live="polite" className="pointer-events-none fixed inset-x-0 bottom-0 z-50 p-4 flex flex-col gap-3 items-end">
{/* 1. Offline Connectivity Banner */}
{!isOnline && (
<div className="pointer-events-auto w-full max-w-md p-3.5 rounded-xl bg-red-950/95 border border-red-800 text-red-100 text-xs flex items-center justify-between shadow-2xl backdrop-blur-md animate-fade-in">
<div className="flex items-center gap-2.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-500 animate-ping shrink-0" />
<span>You are currently offline. Running from local cache; mutations are preserved.</span>
</div>
<span className="font-mono text-[10px] uppercase font-bold text-red-400 bg-red-900/60 px-2 py-0.5 rounded">
Offline
</span>
</div>
)}
{/* 2. New App Update Banner */}
{isUpdateAvailable && (
<div className="pointer-events-auto w-full max-w-md p-4 rounded-2xl bg-bg-surface border border-accent-violet/40 shadow-2xl space-y-3 animate-slide-up">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-xl bg-accent-violet/20 border border-accent-violet/40 flex items-center justify-center text-accent-violet font-bold text-sm shrink-0">
⚡
</div>
<div>
<h4 className="text-sm font-bold text-text-primary">Update Available</h4>
<p className="text-xs text-text-secondary mt-0.5">
A new version has been downloaded in the background. Refresh to activate improvements.
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="button"
onClick={updateApp}
className="px-3.5 py-1.5 text-xs font-semibold bg-accent-violet text-white rounded-lg hover:bg-accent-violet/90 transition-colors cursor-pointer shadow-md"
>
Update Now
</button>
</div>
</div>
)}
{/* 3. Install PWA Prompt */}
{installPrompt && !isStandalone && (
<div className="pointer-events-auto w-full max-w-sm p-4 rounded-2xl bg-bg-surface border border-border shadow-2xl space-y-3 animate-slide-up">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-violet/15 border border-accent-violet/30 flex items-center justify-center text-accent-violet font-bold text-base shrink-0">
AL
</div>
<div className="flex-1">
<h4 className="text-sm font-bold text-text-primary">Install Fullstack Agentic Lab</h4>
<p className="text-xs text-text-secondary mt-0.5">
Launch directly from your home screen with offline course access.
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="button"
onClick={() => setInstallPrompt(null)}
className="px-3 py-1.5 text-xs text-text-muted hover:text-text-primary transition-colors cursor-pointer"
>
Later
</button>
<button
type="button"
onClick={handleInstallClick}
className="px-3 py-1.5 text-xs font-semibold bg-accent-violet text-white rounded-lg hover:bg-accent-violet/90 transition-colors cursor-pointer"
>
Install
</button>
</div>
</div>
)}
</div>
);
}If your web server (Nginx, Cloudflare, Vercel) serves sw.js with Cache-Control: public, max-age=31536000, the browser reads the script from its HTTP cache and never detects updates to the worker logic.
Solution: The /sw.js endpoint must always be served with Cache-Control: no-cache, no-store, must-revalidate.
A naive fetch handler storing all network responses blindly caches upstream server errors. If an API temporarily returns a 500 status code, users become permanently stuck with a cached error page.
Solution: Always validate response.status === 200 before caching, or enforce this via workbox-cacheable-response.
ServiceWorkerGlobalScope has no access to window.localStorage or document. Attempting synchronous disk I/O would block the browser's background packet processing loop.
Solution: Use asynchronous IndexedDB or Cache Storage exclusively within Service Worker threads.
Under poor cellular connectivity ("Lie-Fi"), fetch() calls can stall for up to 60–90 seconds before timing out. The UI remains stuck on a loading spinner even though valid stale data is sitting in local cache.
Solution: Always configure a strict timeout: networkTimeoutSeconds: 3. If the network fails to reply within 3 seconds, serve the cached snapshot immediately.
A worker file served at /static/js/sw.js defaults to a scope of /static/js/. It is structurally unable to intercept requests destined for / or /api/*.
Solution: Always serve sw.js from the origin root (/sw.js), or configure the HTTP response header Service-Worker-Allowed: /.
self.skipWaiting() and self.clients.claim() enable seamless version rollouts without waiting for tabs to close.Cache-First for immutable static assets, Stale-While-Revalidate for instant response with background refresh, and Network-First for fresh critical data.useSyncExternalStore synchronizes navigator.onLine without SSR hydration warnings, while beforeinstallprompt powers branded PWA installation flows.