Service Workers: The Programmable Network Proxy
Turn your website into an Offline-Capable application. A technical guide to the Service Worker lifecycle, Caching Strategies (Workbox), and Background Sync.
The biggest difference between a Native App and a Web App is Resiliency. If you open Instagram in Airplane Mode, you see cached photos. If you open a standard website in Airplane Mode, you see the Dinosaur.
This is unacceptable for modern software. Service Workers are the solution. They are a JavaScript worker script that runs in the background, separate from a web page. They act as a Client-Side Network Proxy.
At Maison Code Paris, we build Progressive Web Apps (PWAs) that pass the “Subway Test”: Can the user browse the catalog while underground? If the answer is No, the app is broken.
Why Maison Code Discusses This
A white screen is a lost customer. We treat “Offline” as a feature, not an error state. Our PWA standard ensures:
- Resiliency: The app shell loads instantly, even on 2G.
- Engagement: Users can add to cart while offline (Background Sync sends it later).
- Retention: Push Notifications drive a 3x higher return rate than email for our mobile-first clients. We don’t build “Websites”; we build “Installable Web Apps”.
The Mental Model: The Man in the Middle
Normally: Browser -> Network -> Server.
With SW: Browser -> Service Worker.
The Service Worker then decides:
- “I have this in Cache. Return Cache.” (0ms latency).
- “I need to go to Network.” (Standard latency).
- “Go to Network, but if it fails, return Cache.” (Resiliency).
This logic is programmable. You control every byte.
The Lifecycle (The Hard Part)
Service Workers are notoriously hard to debug because they live independently of the Tab.
- Install: Browser downloads
sw.js. It downloads critical assets (Precache). - Activate: The SW starts up. It cleans up old caches. Crucially, it does not control open tabs yet.
- Claim: You must call
clients.claim()to take control of open tabs immediately. - Fetch: The SW creates a
fetchevent listener.
The “New Version” Problem
You deploy v2. User visits site.
The Browser sees sw.js changed. It installs v2 in the background.
But v1 is still running the page!
v2 enters “Waiting” state.
It will only activate when all tabs are closed.
To fix this, we implement a “Update Available” toast in the UI.
// In your React App
if (registration.waiting) {
showToast("Update Available", () => {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
});
}
Caching Strategies (Using Workbox)
Writing raw caching logic is error-prone. We use Google Workbox.
1. Precache (The App Shell)
We download the HTML skeleton, the Logo, and the main JS bundle during Install. These files are “Pinned” to the cache. They are guaranteed to be there.
import { precacheAndRoute } from 'workbox-precaching';
// __WB_MANIFEST is injected by the build tool (Webpack/Vite)
precacheAndRoute(self.__WB_MANIFEST);
2. Stale-While-Revalidate (Dynamic Content)
For API calls (/api/products), we want speed but also freshness.
- Step 1: Return cached JSON immediately. (App renders in 50ms).
- Step 2: Fetch fresh JSON from network.
- Step 3: Update Cache.
- Step 4: Broadcast update to UI (“New prices available”).
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new StaleWhileRevalidate({
cacheName: 'api-cache',
plugins: [
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 3600 }), // Keep for 1 hour
],
})
);
3. Cache First (Immutable Assets)
For product images (which are hosted on CDNs with versioned URLs), we use Cache First. If it’s in cache, return it. Never hit the network.
Offline Storage: IndexedDB
LocalStorage is synchronous. It blocks the main thread.
Service Workers are asynchronous. They cannot access LocalStorage.
You must use IndexedDB.
It is a NoSQL database inside the browser.
We use it to store “Pending Actions”.
Scenario: User clicks “Add to Cart” while offline.
- App detects offline.
- App writes action to IndexedDB:
{ type: 'ADD_TO_CART', id: 123 }. - App Optimistically updates UI (Badge shows “1”).
- Service Worker registers a Background Sync event.
Background Sync
This is the killer feature. When connection returns (even if the user closed the app!), the OS wakes up the Service Worker.
// sw.js
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-cart') {
event.waitUntil(syncCart());
}
});
async function syncCart() {
const db = await openDB('my-store');
const action = await db.get('pending', 'cart');
await fetch('/api/cart', { method: 'POST', body: JSON.stringify(action) });
}
Common Pitfalls
- Caching the Service Worker itself:
Ensure your server sends
Cache-Control: no-cacheforsw.js. If the browser caches the SW file for 24h, you cannot deploy updates for 24h. - Opaque Responses (CORS):
If you fetch an image from
cdn.shopify.comwithout CORS headers, the SW sees an “Opaque Response”. It cannot check the status code. It might cache a 404 error page as an image. Always configure CORS on your buckets. - Quota Exceeded: Browsers limit storage (usually 10-20% of disk space). Always implement Least Recently Used (LRU) eviction policies (using Workbox Expiration Plugin).
10. Periodic Background Sync
Standard Sync works when connection comes back.
Periodic Sync allows the app to wake up in the background (e.g., once every 24 hours) to fetch new content.
Imagine a News App. You want the user to have the morning headlines before they open the app.
registration.periodicSync.register('get-headlines', { minInterval: 24 * 60 * 60 * 1000 });.
Note: This usually requires the PWA to be installed on the home screen.
11. Debugging Tips (Chrome DevTools)
The “Application” tab in Chrome is your best friend.
- Update on Reload: Check this box while developing. It forces the browser to bypass the SW cache for the SW file itself.
- Unregister: Nuclear option. If things are weird, unregister the SW to start fresh.
- Cache Storage: View exactly what JSON blobs are saved. If
api-cacheis empty, your Regex matcher is wrong.
13. Push Notification Payload Encryption
Web Push is encrypted (AES-128-GCM).
The browser generates a public/private key pair.
The server (VAPID) must encrypt the payload with the browser’s public key.
If you send plain JSON, the browser rejects it.
The Service Worker receives the push event, decrypts it (handled by the browser OS), and shows the Notification.
self.registration.showNotification(data.title, { body: data.body, icon: '/icon.png' }).
Wait until the promise resolves (event.waitUntil) to ensure the notification appears even if the SW is killed immediately.
14. Workbox Modules: Use the Platform
Don’t reinvent the wheel.
workbox-precaching, workbox-routing, workbox-strategies, workbox-expiration.
We also use workbox-window in the main thread to communicate with the SW.
const wb = new Workbox('/sw.js'); wb.addEventListener('installed', ...).
This abstracts away the complex navigator.serviceWorker API and handles browser quirks (Safari).
15. The History of Offline Web (AppCache)
Before Service Workers, we had AppCache. It was defined in 3 words: “AppCache is a Douchebag”. It was declarative, strict, and broke everything. If you changed 1 byte in the manifest, the browser downloaded EVERYTHING again. Service Workers replaced it with an Imperative API. You write code. You decide what to do. This shift from Configuration to Code is why Service Workers succeeded where AppCache failed.
16. The Safari Hurdles (iOS)
Apple has a love-hate relationship with PWAs. They support Service Workers, but:
- Storage: They delete data if unused for 7 days (ITP).
- Push: Only supported in iOS 16.4+ (and user MUST add to home screen).
- Sync: Background Sync is extremely limited. We build “Progressive” apps. Meaning: They work perfectly on Chrome. They degrade gracefully on Safari (falling back to Network-Only if SW fails).
17. Conclusion
A Service Worker is not just for “Offline”. It is a performance tool. It decouples the application start time from the network quality. On a flaky 4G connection, a Service Worker makes the difference between a bounce and a sale.
At Maison Code, we treat the Network as “Unreliable Infrastructure” and bake resiliency into the client.
Tired of loading spinners?
Do your mobile users suffer from poor connections?