
Service Worker
- 74 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
service-worker is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- service-worker
- AI & Agent Building
- AI-coding skill
Service Worker by the numbers
- 74 all-time installs (skills.sh)
- +3 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,535 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill service-workerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 74 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Service Worker
Overview
Service workers are event-driven scripts that run in a separate thread from the main page, intercepting network requests, managing caches, and enabling offline functionality. Workbox v7.4 (maintained by the Chrome Aurora team) provides a production-ready abstraction over the low-level Cache API and fetch event handling.
When to use: Progressive web apps needing offline support, apps requiring push notifications, background data synchronization, app shell caching, network request optimization with static routing.
When NOT to use: Simple static sites served from a CDN, server-rendered apps with no offline requirements, apps where stale data is unacceptable (use network-only), prototypes where caching complexity is premature.
Quick Reference
| Pattern | API / Tool | Key Points |
|---|---|---|
| Registration | navigator.serviceWorker.register() | Register in window context, scope defaults to path |
| Precaching | workbox-precaching | Revision-hashed app shell, injected at build time |
| Cache-first | CacheFirst strategy | Static assets, fonts, images |
| Network-first | NetworkFirst strategy | API responses needing freshness |
| Stale-while-revalidate | StaleWhileRevalidate strategy | Balance between speed and freshness |
| Background sync | workbox-background-sync | Replay failed requests when back online |
| Push notifications | Push API + Notifications API | VAPID keys, server-sent push, offline-first display |
| Static routing | event.addRoutes() in install | Bypass fetch handler for known routes |
| Skip waiting | self.skipWaiting() | Activate new SW immediately, use with caution |
| Clients claim | self.clients.claim() | Control existing tabs without reload |
| Navigation preload | navigationPreload.enable() | Parallel network request during SW startup |
| Update prompt | workbox-window Workbox class | Detect updates, prompt user, postMessage to SW |
| Offline fallback | workbox-recipes offlineFallback | Serve fallback page when cache and network fail |
| Cache expiration | workbox-expiration | maxEntries and maxAgeSeconds per cache |
Browser Support Quick Reference
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Service Workers | 40+ | 44+ | 11.1+ | 17+ |
| Background Sync | 49+ | No | No | 79+ |
| Periodic Sync | 80+ | No | No | 80+ |
| Push API | 50+ | 44+ | 16.4+ | 17+ |
| Navigation Preload | 59+ | 99+ | 15.4+ | 18+ |
| Static Routing API | 123+ | No | No | 123+ |
| Background Fetch | 74+ | No | No | 79+ |
| Storage Persistence | 52+ | 55+ | 15.2+ | 79+ |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
| Caching API responses with CacheFirst | Use NetworkFirst or StaleWhileRevalidate for dynamic data |
| Calling skipWaiting unconditionally | Prompt user before activating to avoid breaking in-flight navigations |
| Not versioning precache manifests | Use workbox-build or workbox-webpack-plugin to inject revision hashes |
| Ignoring cache storage limits | Implement cache expiration with workbox-expiration plugin |
| Using sync access handle in service worker | OPFS createSyncAccessHandle is dedicated-worker only; use async methods |
| Assuming Background Sync works everywhere | Chrome/Edge only (~80%); implement navigator.onLine fallback |
| Caching opaque responses without size limits | Opaque responses pad cache quota; limit with maxEntries |
| Not handling service worker update races | Use workbox-window to coordinate update lifecycle with the page |
| Registering SW before page load completes | Register in window load event to avoid competing for bandwidth |
| Letting sync engine data flow through SW cache | Let the sync engine handle data; SW handles app shell and static assets |
| Not cleaning up old caches on activate | Delete outdated caches in activate event to free storage |
| Missing offline fallback for navigations | Precache an offline.html and serve it when network and cache both fail |
Workbox Package Reference
| Package | Purpose |
|---|---|
workbox-precaching | Build-time asset caching with revision management |
workbox-routing | URL pattern matching and strategy routing |
workbox-strategies | CacheFirst, NetworkFirst, StaleWhileRevalidate |
workbox-expiration | Cache size and age limits |
workbox-cacheable-response | Filter responses by status or header |
workbox-background-sync | Queue and replay failed requests |
workbox-window | Page-side registration and update management |
workbox-recipes | Pre-built patterns (offline fallback, warm cache) |
workbox-build | Build tool for generating precache manifests |
workbox-navigation-preload | Enable navigation preload in activate |
Delegation
If the local-first skill is available, delegate architecture decisions, sync engine selection, and offline data strategy to it.Otherwise, recommend: pnpm dlx skills add oakoss/agent-skills -s local-first -a claude-code -yIf the electricsql skill is available, delegate sync engine patterns, shape subscriptions, and real-time data replication to it.Otherwise, recommend: pnpm dlx skills add oakoss/agent-skills -s electricsql -a claude-code -y- Caching strategy review: Use
Taskagent to audit route-to-strategy mappings - Offline testing: Use
Taskagent to simulate offline scenarios and verify fallback behavior - Push notification design: Use
Planagent to design notification payload and action strategies
References
- Service worker lifecycle and update management
- Workbox caching strategies and precaching
- Background sync with cross-browser fallbacks
- Static Routing API for fetch handler bypass
- Push notifications with VAPID and offline integration
- Local-first integration and storage patterns
Background Sync
Browser Support
The Background Sync API is supported in Chrome and Edge only (~80% global support). Firefox and Safari do not support it. Any implementation must include a fallback strategy for cross-browser compatibility.
How Background Sync Works
Page sends request ──> SW intercepts ──> Network fails
│
└── Request queued in IndexedDB
│
└── Browser fires "sync" event when connectivity returns
│
└── SW replays queued requestsThe browser controls when the sync event fires. It uses exponential backoff and may coalesce multiple sync registrations.
Workbox Background Sync Plugin
The simplest approach uses BackgroundSyncPlugin as a plugin on a strategy. It automatically queues failed requests and replays them:
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';
import { BackgroundSyncPlugin } from 'workbox-background-sync';
const bgSyncPlugin = new BackgroundSyncPlugin('api-queue', {
maxRetentionTime: 24 * 60,
onSync: async ({ queue }) => {
let entry;
while ((entry = await queue.shiftRequest())) {
try {
await fetch(entry.request);
} catch (error) {
await queue.unshiftRequest(entry);
throw error;
}
}
},
});
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkOnly({ plugins: [bgSyncPlugin] }),
'POST',
);maxRetentionTime is in minutes. Requests older than this are discarded on the next sync.
Queue Class for Fine-Grained Control
The Queue class provides direct control over the request queue:
import { Queue } from 'workbox-background-sync';
const queue = new Queue('mutations', {
maxRetentionTime: 7 * 24 * 60,
onSync: async ({ queue }) => {
let entry;
while ((entry = await queue.shiftRequest())) {
try {
await fetch(entry.request);
} catch (error) {
await queue.unshiftRequest(entry);
throw error;
}
}
},
});
self.addEventListener('fetch', (event: FetchEvent) => {
if (
event.request.method === 'POST' &&
event.request.url.includes('/api/mutations')
) {
const bgSyncLogic = async () => {
try {
return await fetch(event.request.clone());
} catch (error) {
await queue.pushRequest({ request: event.request });
return new Response(JSON.stringify({ queued: true }), {
headers: { 'Content-Type': 'application/json' },
});
}
};
event.respondWith(bgSyncLogic());
}
});Queue API Methods
| Method | Description |
|---|---|
pushRequest({ request }) | Add request to end of queue |
unshiftRequest({ request }) | Add request to front of queue (for retry) |
shiftRequest() | Remove and return first request |
popRequest() | Remove and return last request |
getAll() | Return all queued requests (without removing) |
size() | Return number of queued requests |
Idempotency Keys
Background sync may replay requests that the server already received (if the original response was lost). Always include idempotency keys:
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.method === 'POST' && event.request.url.includes('/api/')) {
const clonedRequest = event.request.clone();
const addIdempotencyKey = async () => {
const body = await clonedRequest.json();
if (!body.idempotencyKey) {
body.idempotencyKey = crypto.randomUUID();
}
return new Request(event.request.url, {
method: 'POST',
headers: event.request.headers,
body: JSON.stringify(body),
});
};
event.respondWith(addIdempotencyKey().then((request) => fetch(request)));
}
});The server must check the idempotency key and return the cached response for duplicate requests.
Cross-Browser Fallback Pattern
For Firefox and Safari, implement an IndexedDB-based write queue with online/offline detection:
import { openDB, type IDBPDatabase } from 'idb';
interface QueuedMutation {
id: string;
url: string;
method: string;
body: string;
headers: Record<string, string>;
timestamp: number;
idempotencyKey: string;
}
async function getDB(): Promise<IDBPDatabase> {
return openDB('offline-queue', 1, {
upgrade(db) {
db.createObjectStore('mutations', { keyPath: 'id' });
},
});
}
async function queueMutation(
mutation: Omit<QueuedMutation, 'id' | 'timestamp'>,
): Promise<void> {
const db = await getDB();
await db.put('mutations', {
...mutation,
id: crypto.randomUUID(),
timestamp: Date.now(),
});
}
async function flushQueue(): Promise<void> {
const db = await getDB();
const mutations = await db.getAll('mutations');
for (const mutation of mutations) {
try {
await fetch(mutation.url, {
method: mutation.method,
headers: {
...mutation.headers,
'Idempotency-Key': mutation.idempotencyKey,
},
body: mutation.body,
});
await db.delete('mutations', mutation.id);
} catch {
break;
}
}
}
window.addEventListener('online', flushQueue);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && navigator.onLine) {
flushQueue();
}
});Hybrid Approach
Combine Background Sync API (when available) with the IndexedDB fallback:
async function submitMutation(url: string, body: string): Promise<Response> {
try {
return await fetch(url, { method: 'POST', body });
} catch {
if ('serviceWorker' in navigator && 'SyncManager' in window) {
await queueInServiceWorker(url, body);
} else {
await queueMutation({
url,
method: 'POST',
body,
headers: { 'Content-Type': 'application/json' },
idempotencyKey: crypto.randomUUID(),
});
}
return new Response(JSON.stringify({ queued: true }), {
status: 202,
headers: { 'Content-Type': 'application/json' },
});
}
}Periodic Background Sync
Periodic Background Sync runs at intervals, but it is Chromium-only and requires the app to be installed as a PWA:
const registration = await navigator.serviceWorker.ready;
const status = await navigator.permissions.query({
name: 'periodic-background-sync' as PermissionName,
});
if (status.state === 'granted') {
await registration.periodicSync.register('content-sync', {
minInterval: 24 * 60 * 60 * 1000,
});
}In the service worker:
self.addEventListener('periodicsync', (event: Event) => {
const syncEvent = event as ExtendableEvent & { tag: string };
if (syncEvent.tag === 'content-sync') {
syncEvent.waitUntil(syncContent());
}
});The browser determines actual interval based on site engagement score. minInterval is a hint, not a guarantee.
Testing Background Sync
Chrome DevTools > Application > Service Workers has a "Sync" button to trigger sync events manually. Use the "Offline" checkbox in the Network panel to simulate connectivity loss.
For automated testing:
// In SW test harness
const syncEvent = new ExtendableEvent('sync');
Object.defineProperty(syncEvent, 'tag', { value: 'api-queue' });
self.dispatchEvent(syncEvent);Caching Strategies
Workbox v7.4 Strategy Overview
Workbox strategies encapsulate cache-vs-network logic into reusable classes. Each strategy is registered against URL patterns via registerRoute:
import { registerRoute } from 'workbox-routing';
import { CacheFirst } from 'workbox-strategies';
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({ cacheName: 'images' }),
);Built-in Strategies
CacheFirst
Serves from cache if available, falls back to network. Best for versioned static assets that do not change at a given URL:
import { CacheFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
registerRoute(
({ request }) =>
request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'font',
new CacheFirst({
cacheName: 'static-assets',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
],
}),
);NetworkFirst
Tries network, falls back to cache on failure. Best for dynamic content that should be fresh but available offline:
import { NetworkFirst } from 'workbox-strategies';
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-responses',
networkTimeoutSeconds: 3,
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60,
}),
],
}),
);networkTimeoutSeconds sets how long to wait before falling back to cache. Omit for unlimited wait.
StaleWhileRevalidate
Serves from cache immediately while fetching an update in the background. Best for resources where slight staleness is acceptable:
import { StaleWhileRevalidate } from 'workbox-strategies';
registerRoute(
({ url }) => url.pathname.startsWith('/content/'),
new StaleWhileRevalidate({
cacheName: 'content-cache',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 50 }),
],
}),
);NetworkOnly
Always goes to network. Use for non-cacheable requests like analytics pings or POST requests:
import { NetworkOnly } from 'workbox-strategies';
registerRoute(
({ url }) => url.pathname.startsWith('/analytics/'),
new NetworkOnly(),
);CacheOnly
Only serves from cache, never hits network. Use for precached resources where a network fallback is unnecessary:
import { CacheOnly } from 'workbox-strategies';
registerRoute(
({ url }) => url.pathname === '/offline.html',
new CacheOnly({ cacheName: 'precache' }),
);Strategy Selection Guide
| Content Type | Strategy | Reason |
|---|---|---|
| App shell HTML | Precache + CacheFirst | Versioned at build time |
| Hashed JS/CSS bundles | CacheFirst | URL changes on content change |
| Google Fonts | StaleWhileRevalidate | Rarely change, staleness acceptable |
| API responses | NetworkFirst | Freshness matters, offline fallback |
| User avatars | StaleWhileRevalidate | Show fast, update in background |
| Analytics | NetworkOnly | No value in caching |
| Offline fallback page | CacheOnly (precached) | Must be available without network |
Precaching with workbox-precaching
Precaching downloads and caches resources at install time. Workbox uses a manifest with revision hashes to manage updates:
import { precacheAndRoute } from 'workbox-precaching';
precacheAndRoute(self.__WB_MANIFEST);self.__WB_MANIFEST is replaced at build time by workbox-build, workbox-webpack-plugin, or vite-plugin-pwa. The generated manifest looks like:
[
{ "url": "/index.html", "revision": "abc123" },
{ "url": "/app.js", "revision": null },
{ "url": "/styles.css", "revision": null }
]Files with content hashes in their filenames (e.g., app.a1b2c3.js) set revision: null because the URL itself changes on content change.
Precache and Routing
precacheAndRoute automatically sets up a CacheFirst route for precached URLs. It handles revision parameter stripping and index.html redirects:
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
cleanupOutdatedCaches();
precacheAndRoute(self.__WB_MANIFEST);cleanupOutdatedCaches removes caches from previous Workbox versions.
Plugin Lifecycle Hooks
Workbox plugins are objects implementing lifecycle callbacks. They run at specific points during request/response handling:
| Hook | When | Use Case |
|---|---|---|
cacheWillUpdate | Before response enters cache | Filter non-200 responses |
cacheDidUpdate | After cache is updated | Notify page of new content |
cacheKeyWillBeUsed | Before cache key lookup | Normalize URLs, strip params |
cachedResponseWillBeUsed | Before cached response is used | Validate freshness, check headers |
requestWillFetch | Before network request | Add auth headers |
fetchDidFail | After network request fails | Log failures, queue for retry |
fetchDidSucceed | After successful network response | Log response times |
handlerWillStart | Before strategy starts | Start timing |
handlerWillRespond | Before response is returned | Modify response |
handlerDidRespond | After response is returned | Log cache hit/miss |
handlerDidComplete | After all work is done | End timing, report metrics |
handlerDidError | After all sources fail | Return offline fallback |
Custom Plugin Example
const cacheNotificationPlugin = {
cacheDidUpdate: async ({ cacheName, request, oldResponse, newResponse }) => {
if (oldResponse) {
const clients = await self.clients.matchAll();
for (const client of clients) {
client.postMessage({
type: 'CACHE_UPDATED',
url: request.url,
cacheName,
});
}
}
},
};Custom Strategy
Extend the Strategy base class for custom logic:
import { Strategy, type StrategyHandler } from 'workbox-strategies';
class CacheFirstWithRefresh extends Strategy {
async _handle(request: Request, handler: StrategyHandler): Promise<Response> {
const cachedResponse = await handler.cacheMatch(request);
const fetchPromise = handler.fetchAndCachePut(request).catch(() => {});
return cachedResponse || (await handler.fetch(request));
}
}Offline Fallback Pattern
Use the handlerDidError hook to serve a fallback page when both cache and network fail:
import { offlineFallback } from 'workbox-recipes';
offlineFallback({
pageFallback: '/offline.html',
imageFallback: '/fallback-image.svg',
fontFallback: '/fallback-font.woff2',
});Or manually with setCatchHandler:
import { setCatchHandler } from 'workbox-routing';
setCatchHandler(async ({ event }) => {
if (event.request.destination === 'document') {
return caches.match('/offline.html');
}
return Response.error();
});Cache Storage Considerations
- Opaque responses (cross-origin, no CORS) pad cache storage quota significantly (at least 7 MB each in Chrome)
- Always set `maxEntries` on expiration plugins to prevent unbounded cache growth
- Use `CacheableResponsePlugin` with
statuses: [0, 200]to cache opaque responses intentionally - Storage quota varies by browser: Chrome grants per-origin quota based on available disk space; check with
navigator.storage.estimate()
Service Worker Lifecycle
Lifecycle Phases
Installing ──> Waiting ──> Activating ──> Activated (Functional)
│ │ │
│ │ └── Controls pages, receives fetch/push/sync events
│ └── Old SW still controls pages; new SW waits
└── Caching app shell; failure here aborts installationA service worker progresses through install, wait, and activate before it can handle functional events (fetch, push, sync, message).
Registration
Register in the window context after the page loads to avoid competing for network bandwidth during initial render:
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
console.log('SW registered with scope:', registration.scope);
});
}Scope defaults to the directory containing the SW script. A SW at /app/sw.js controls /app/* by default. The Service-Worker-Allowed header can widen scope beyond the script directory.
Install Event
The install event fires once per SW version. Use it to precache critical resources:
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches
.open('app-shell-v1')
.then((cache) =>
cache.addAll(['/', '/index.html', '/styles.css', '/app.js']),
),
);
});If any resource in cache.addAll fails to fetch, the entire install aborts. Use cache.add individually for non-critical resources where partial success is acceptable.
Waiting Phase
When a new SW installs while an existing SW controls pages, the new SW enters the waiting state. It remains waiting until all tabs controlled by the old SW are closed.
This prevents breaking in-flight pages that depend on the old SW's cached resources.
skipWaiting and clients.claim
skipWaiting forces the waiting SW to become active immediately. clients.claim makes the newly active SW take control of existing pages without requiring a reload:
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(self.clients.claim());
});Use with caution. Calling skipWaiting unconditionally means the new SW takes over pages that loaded with resources cached by the old SW. This can cause broken asset references if the new SW has a different precache manifest. The recommended approach is to prompt the user before activating.
Activate Event
The activate event fires when the SW takes control. Use it to clean up old caches:
const CURRENT_CACHES = ['app-shell-v2', 'api-cache-v1'];
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
caches
.keys()
.then((cacheNames) =>
Promise.all(
cacheNames
.filter((name) => !CURRENT_CACHES.includes(name))
.map((name) => caches.delete(name)),
),
),
);
});Update Detection with workbox-window
The Workbox class from workbox-window provides a clean API for detecting updates and prompting users in the page context:
import { Workbox } from 'workbox-window';
if ('serviceWorker' in navigator) {
const wb = new Workbox('/sw.js');
wb.addEventListener('waiting', () => {
const shouldUpdate = confirm('New version available. Reload to update?');
if (shouldUpdate) {
wb.messageSkipWaiting();
window.location.reload();
}
});
wb.register();
}Inside the service worker, listen for the skip waiting message:
self.addEventListener('message', (event: ExtendableMessageEvent) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});Update Lifecycle Events
workbox-window exposes granular lifecycle events:
| Event | When | Use Case |
|---|---|---|
installed | New SW installed (first time) | Show "app ready for offline" message |
waiting | New SW installed, waiting for old to release | Show update prompt |
controlling | New SW took control of page | Safe to reload or update UI |
activated | New SW activated | Clean up, log version |
externalinstalled | SW installed by another tab | Coordinate multi-tab updates |
externalwaiting | SW from another tab is waiting | Show update notification |
Checking for Updates Programmatically
const registration = await navigator.serviceWorker.ready;
await registration.update();The browser also checks for updates automatically on navigation (if 24+ hours since last check) and on functional events like push/sync.
Registration Patterns for Frameworks
Vite with vite-plugin-pwa
import { registerSW } from 'virtual:pwa-register';
const updateSW = registerSW({
onNeedRefresh() {
const shouldUpdate = confirm('New content available. Reload?');
if (shouldUpdate) {
updateSW();
}
},
onOfflineReady() {
console.log('App ready for offline use');
},
});Next.js with next-pwa (or Serwist)
Service worker registration is handled automatically by the plugin. Configure in next.config.js:
import withPWA from 'next-pwa';
export default withPWA({
dest: 'public',
register: true,
skipWaiting: false,
})(nextConfig);Debugging Lifecycle Issues
Common debugging steps:
1. Chrome DevTools > Application > Service Workers shows lifecycle state 2. "Update on reload" checkbox forces install + activate on every navigation (dev only) 3. "Bypass for network" disables fetch handling without unregistering 4. chrome://serviceworker-internals shows all registered SWs across origins
Unregistration
To remove a service worker entirely:
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((r) => r.unregister()));Unregistration does not clear caches. Delete caches separately:
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((name) => caches.delete(name)));Local-First Patterns
Separation of Concerns
In a local-first architecture with a sync engine, the service worker and the sync engine have distinct responsibilities:
| Responsibility | Service Worker | Sync Engine (e.g., Electric) |
|---|---|---|
| App shell caching | Yes -- precache HTML/JS/CSS | No |
| Static asset caching | Yes -- images, fonts, icons | No |
| API response caching | Selective (non-synced endpoints) | No |
| Application data sync | No | Yes -- handles reads, writes, sync |
| Offline data access | No (data lives in sync engine DB) | Yes -- local DB is source of truth |
| Background sync | Queues for non-synced mutations | Handles its own sync protocol |
| Push notifications | Yes -- shows notifications | May trigger via server events |
The key principle: let the sync engine handle all application data. The service worker handles everything else -- the app shell, static assets, and browser APIs like push and background sync.
App Shell Strategy with Sync Engine
Precache the app shell so the application loads instantly, then the sync engine handles data:
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { NetworkFirst } from 'workbox-strategies';
precacheAndRoute(self.__WB_MANIFEST);
const navigationHandler = new NetworkFirst({
cacheName: 'navigations',
networkTimeoutSeconds: 3,
});
registerRoute(new NavigationRoute(navigationHandler));Do not cache API endpoints that the sync engine manages. The sync engine maintains its own local database and handles replication independently.
IndexedDB vs Cache API
These two storage mechanisms serve different purposes:
| Feature | IndexedDB | Cache API |
|---|---|---|
| Data model | Structured key-value / object store | Request-Response pairs |
| Query capability | Indexes, key ranges, cursors | URL matching only |
| Best for | Application data, offline queues | HTTP responses, static assets |
| Access from SW | Yes | Yes |
| Transactions | Yes (read/write) | No |
| Size limit | Large (quota-managed) | Large (quota-managed) |
When to Use Each
- Cache API: HTTP responses, precached assets, API response caching, offline fallback pages
- IndexedDB: Application state, offline mutation queues, sync engine storage, user preferences, structured data
Sync engines typically use IndexedDB (or OPFS) as their backing store. The service worker uses the Cache API for asset caching.
OPFS in Service Workers
The Origin Private File System (OPFS) provides fast file-based storage. However, service workers have a critical limitation:
createSyncAccessHandle()is not available in service workers (dedicated workers only)- Service workers can only use the async OPFS API
async function writeToOPFS(filename: string, data: string): Promise<void> {
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle(filename, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
}
async function readFromOPFS(filename: string): Promise<string> {
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle(filename);
const file = await fileHandle.getFile();
return file.text();
}The async API is significantly slower than the sync access handle. For performance-critical storage in service workers, IndexedDB is generally a better choice.
OPFS Use Cases in Service Workers
- Storing large binary assets (images, WASM modules) that do not fit well in Cache API
- Temporary file staging for Background Fetch downloads
- Log files for debugging service worker behavior
Storage Quota Management
All storage APIs (Cache API, IndexedDB, OPFS) share a single origin quota:
async function checkStorageQuota(): Promise<{
usage: number;
quota: number;
percentUsed: number;
}> {
const estimate = await navigator.storage.estimate();
const usage = estimate.usage ?? 0;
const quota = estimate.quota ?? 0;
return {
usage,
quota,
percentUsed: quota > 0 ? (usage / quota) * 100 : 0,
};
}Requesting Persistent Storage
By default, the browser can evict storage under pressure. Request persistent storage to protect critical data:
async function requestPersistentStorage(): Promise<boolean> {
if (navigator.storage && navigator.storage.persist) {
return navigator.storage.persist();
}
return false;
}Chrome auto-grants persistent storage for installed PWAs and sites with high engagement. Firefox prompts the user. Safari grants it for home screen apps.
Storage Eviction Strategy
When quota is running low, prioritize what to keep:
async function evictLowPriorityCaches(): Promise<void> {
const { usage, quota } = await checkStorageQuota();
if (quota > 0 && usage / quota > 0.8) {
const lowPriorityCaches = ['api-responses', 'images', 'content-cache'];
for (const cacheName of lowPriorityCaches) {
await caches.delete(cacheName);
}
}
}Never evict the precache or the sync engine's IndexedDB database. Prioritize evicting runtime caches for API responses and images.
Navigation Preload
Navigation Preload starts a network request for navigation in parallel with service worker startup, reducing latency:
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
})(),
);
});
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.mode === 'navigate') {
event.respondWith(
(async () => {
const cachedResponse = await caches.match('/app-shell.html');
const preloadResponse = await event.preloadResponse;
if (preloadResponse) {
return preloadResponse;
}
return cachedResponse ?? fetch(event.request);
})(),
);
}
});Browser Support
| Browser | Version | Notes |
|---|---|---|
| Chrome | 59+ | Full support |
| Edge | 18+ | Full support |
| Firefox | 99+ | Full support |
| Safari | 15.4+ | Full support |
Navigation Preload with App Shell
For local-first apps that always serve a cached app shell, Navigation Preload is less critical because the shell is served from cache instantly. However, it is valuable when the app shell is served network-first (e.g., for server-rendered content):
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.mode === 'navigate') {
event.respondWith(
(async () => {
try {
const preloadResponse = await event.preloadResponse;
if (preloadResponse) {
const cache = await caches.open('navigations');
await cache.put(event.request, preloadResponse.clone());
return preloadResponse;
}
return await fetch(event.request);
} catch {
return (await caches.match('/offline.html'))!;
}
})(),
);
}
});Background Fetch for Large Downloads
Background Fetch (Chrome 74+, experimental) handles large downloads that may outlive the service worker:
async function startBackgroundDownload(
urls: string[],
title: string,
): Promise<void> {
const registration = await navigator.serviceWorker.ready;
await registration.backgroundFetch.fetch('large-download', urls, {
title,
icons: [
{ src: '/icons/download.png', sizes: '192x192', type: 'image/png' },
],
downloadTotal: 50 * 1024 * 1024,
});
}In the service worker:
self.addEventListener('backgroundfetchsuccess', (event: Event) => {
const bgFetchEvent = event as BackgroundFetchEvent & {
registration: BackgroundFetchRegistration;
updateUI: (options: { title: string }) => Promise<void>;
};
bgFetchEvent.waitUntil(
(async () => {
const cache = await caches.open('downloads');
const records = await bgFetchEvent.registration.matchAll();
for (const record of records) {
const response = await record.responseReady;
await cache.put(record.request, response);
}
await bgFetchEvent.updateUI({ title: 'Download complete' });
})(),
);
});Background Fetch is useful for large media files, datasets, or WASM modules that cannot be reliably downloaded in a single fetch handler execution.
Push Notifications
Browser Support
The Push API has ~96% browser support, including Safari 16.4+ (with differences). Safari requires the app to be added to the home screen for push to work on iOS.
Architecture
App Server ──> Push Service (FCM/APNs/Mozilla) ──> Browser ──> Service Worker
│
push event fires
│
show notificationThe push flow involves three parties: the application server, the browser's push service, and the service worker. VAPID keys authenticate the application server to the push service.
VAPID Key Generation
Generate a VAPID key pair once and store securely:
npx web-push generate-vapid-keysOr programmatically:
import webPush from 'web-push';
const vapidKeys = webPush.generateVAPIDKeys();
// Store vapidKeys.publicKey and vapidKeys.privateKey securelySubscription Flow
Request Permission and Subscribe (Client)
async function subscribeToPush(): Promise<PushSubscription | null> {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
return null;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
return subscription;
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}userVisibleOnly: true is required -- browsers mandate that push messages result in a visible notification.
Check Existing Subscription
async function getExistingSubscription(): Promise<PushSubscription | null> {
const registration = await navigator.serviceWorker.ready;
return registration.pushManager.getSubscription();
}Unsubscribe
async function unsubscribe(): Promise<void> {
const subscription = await getExistingSubscription();
if (subscription) {
await subscription.unsubscribe();
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
}
}Sending Push Messages (Server)
import webPush from 'web-push';
webPush.setVapidDetails(
'mailto:admin@example.com',
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!,
);
async function sendPush(
subscription: webPush.PushSubscription,
payload: { title: string; body: string; url?: string; tag?: string },
): Promise<void> {
try {
await webPush.sendNotification(subscription, JSON.stringify(payload), {
TTL: 60 * 60,
urgency: 'normal',
});
} catch (error: unknown) {
if (error instanceof webPush.WebPushError && error.statusCode === 410) {
await removeSubscription(subscription.endpoint);
}
throw error;
}
}TTL (Time To Live) in seconds determines how long the push service stores the message if the device is offline. A 410 Gone response means the subscription expired and should be removed.
Service Worker Push Event Handler
self.addEventListener('push', (event: PushEvent) => {
const data = event.data?.json() ?? {
title: 'Notification',
body: 'New update available',
};
const options: NotificationOptions = {
body: data.body,
icon: '/icons/notification-192.png',
badge: '/icons/badge-72.png',
tag: data.tag ?? 'default',
renotify: Boolean(data.tag),
data: { url: data.url ?? '/' },
actions: data.actions ?? [],
vibrate: [200, 100, 200],
};
event.waitUntil(self.registration.showNotification(data.title, options));
});Notification Options
| Option | Type | Description |
|---|---|---|
body | string | Notification body text |
icon | string | Large icon URL |
badge | string | Small monochrome icon (Android) |
image | string | Large image displayed in notification |
tag | string | Groups notifications; same tag replaces |
renotify | boolean | Alert again when replacing tagged notification |
data | any | Custom data passed to notificationclick |
actions | NotificationAction[] | Up to 2 action buttons |
silent | boolean | Suppress sound/vibration |
vibrate | number[] | Vibration pattern in ms |
requireInteraction | boolean | Keep notification visible until dismissed |
Handling Notification Clicks
self.addEventListener('notificationclick', (event: NotificationEvent) => {
event.notification.close();
const targetUrl = event.notification.data?.url ?? '/';
const action = event.action;
if (action === 'view') {
event.waitUntil(openOrFocusWindow(targetUrl));
} else if (action === 'dismiss') {
return;
} else {
event.waitUntil(openOrFocusWindow(targetUrl));
}
});
async function openOrFocusWindow(url: string): Promise<WindowClient | null> {
const clients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
});
for (const client of clients) {
if (new URL(client.url).pathname === url && 'focus' in client) {
return client.focus();
}
}
return self.clients.openWindow(url);
}Notification Actions
const options: NotificationOptions = {
body: 'You have a new message from Alice',
actions: [
{ action: 'view', title: 'View', icon: '/icons/view.png' },
{ action: 'dismiss', title: 'Dismiss', icon: '/icons/dismiss.png' },
],
data: { url: '/messages/123' },
};Action support is limited to two buttons on most platforms. Chrome on Android supports actions; desktop Chrome and Safari show the notification without action buttons.
Offline-First Push Integration
When a push arrives while the device is offline or has stale data, pre-sync relevant data before showing the notification:
self.addEventListener('push', (event: PushEvent) => {
const data = event.data?.json();
event.waitUntil(
(async () => {
if (data.prefetch) {
const cache = await caches.open('push-prefetch');
await Promise.allSettled(
data.prefetch.map((url: string) =>
fetch(url)
.then((response) => cache.put(url, response))
.catch(() => {}),
),
);
}
await self.registration.showNotification(data.title, {
body: data.body,
data: { url: data.url },
});
})(),
);
});This ensures that when the user taps the notification and opens the app, the relevant data is already cached.
Permission Best Practices
- Never request permission on page load; ask after user interaction that indicates intent
- Explain what notifications will be used for before requesting permission
- Provide an in-app toggle to manage subscription independent of browser settings
- Handle the
deniedstate gracefully; once denied, the permission prompt cannot be shown again (user must change it in browser settings)
function canRequestPermission(): boolean {
return 'Notification' in window && Notification.permission === 'default';
}Safari-Specific Considerations
- Push requires the app to be added to the home screen on iOS (16.4+)
- Safari on macOS supports push in normal browsing (16.1+)
- Safari does not support notification actions (action buttons)
- Safari does not support the
imagenotification option - Badge icons are not supported on Safari
Static Routing
Overview
The Service Worker Static Routing API (Chrome 123+) lets service workers declare routing rules at install time. The browser evaluates these rules before starting the service worker, bypassing the fetch event handler entirely for matched routes. This eliminates service worker startup latency for known routing decisions.
Browser Support
- Chrome 123+: Full support
- Edge 123+: Full support (Chromium-based)
- Firefox: Not supported
- Safari: Not supported
Feature-detect before using:
self.addEventListener('install', (event: ExtendableEvent) => {
if ('addRoutes' in event) {
(event as ExtendableEvent & { addRoutes: Function }).addRoutes([
/* rules */
]);
}
});Basic Usage
Declare routes in the install event using event.addRoutes():
self.addEventListener('install', (event: ExtendableEvent) => {
const installEvent = event as ExtendableEvent & {
addRoutes: (routes: StaticRoute[]) => void;
};
if ('addRoutes' in installEvent) {
installEvent.addRoutes([
{
condition: {
urlPattern: new URLPattern({ pathname: '/api/*' }),
},
source: 'network',
},
{
condition: {
urlPattern: new URLPattern({ pathname: '/static/*' }),
},
source: 'cache',
},
]);
}
});Conditions
Conditions determine which requests match a rule:
urlPattern
Uses the URLPattern API to match request URLs:
{
condition: {
urlPattern: new URLPattern({ pathname: '/images/*' }),
},
source: 'network',
}URLPattern supports wildcards, named groups, and regex:
new URLPattern({ pathname: '/users/:id' });
new URLPattern({ pathname: '/docs/:category/:slug' });
new URLPattern({ pathname: '/*.{png,jpg,webp}' });String shorthand is also accepted:
{
condition: { urlPattern: '/api/*' },
source: 'network',
}requestMethod
Filter by HTTP method:
{
condition: {
urlPattern: new URLPattern({ pathname: '/api/*' }),
requestMethod: 'GET',
},
source: 'network',
}runningStatus
Match based on whether the service worker is already running:
{
condition: {
urlPattern: new URLPattern({ pathname: '/*' }),
runningStatus: 'not-running',
},
source: 'network',
}| Value | Meaning |
|---|---|
running | SW is already started |
not-running | SW would need to start to handle request |
This is useful for bypassing the fetch handler only when the SW is cold, avoiding startup cost.
Sources
Sources determine how matched requests are handled:
network
Bypass the service worker entirely and go straight to the network:
{
condition: { urlPattern: '/api/*' },
source: 'network',
}Best for: API calls that should never be intercepted, analytics endpoints, real-time data.
cache
Serve directly from a named cache without waking the service worker:
{
condition: {
urlPattern: new URLPattern({ pathname: '/shell/*' }),
},
source: {
type: 'cache',
cacheName: 'app-shell-v1',
},
}Best for: Precached app shell resources, static assets with known cache names.
fetch-event
Fall through to the service worker's fetch event handler (default behavior):
{
condition: { urlPattern: '/dynamic/*' },
source: 'fetch-event',
}Explicitly opting into fetch handler processing. This is the default when no static route matches, so this source is primarily useful for clarity or overriding other rules.
race-network-and-fetch-handler
Race the network request against the service worker's fetch handler. Whichever responds first wins:
{
condition: {
urlPattern: new URLPattern({ pathname: '/' }),
runningStatus: 'not-running',
},
source: 'race-network-and-fetch-handler',
}Best for: Navigation requests when the SW is cold. The network request starts immediately while the SW boots up. If the network is fast, the response arrives before the SW is ready. If the SW is faster (e.g., cache hit), it wins the race.
Practical Configuration
A complete static routing setup for a typical PWA:
self.addEventListener('install', (event: ExtendableEvent) => {
const installEvent = event as ExtendableEvent & {
addRoutes: (routes: StaticRoute[]) => void;
};
if (!('addRoutes' in installEvent)) {
return;
}
installEvent.addRoutes([
{
condition: {
urlPattern: new URLPattern({ pathname: '/api/*' }),
requestMethod: 'GET',
runningStatus: 'not-running',
},
source: 'network',
},
{
condition: {
urlPattern: new URLPattern({ pathname: '/' }),
runningStatus: 'not-running',
},
source: 'race-network-and-fetch-handler',
},
{
condition: {
urlPattern: new URLPattern({ pathname: '/analytics/*' }),
},
source: 'network',
},
{
condition: {
urlPattern: new URLPattern({ pathname: '/static/*' }),
},
source: {
type: 'cache',
cacheName: 'static-assets',
},
},
]);
});Rule Evaluation Order
Rules are evaluated in declaration order. The first matching rule wins. Place more specific rules before general ones:
installEvent.addRoutes([
// Specific: analytics always bypasses SW
{ condition: { urlPattern: '/api/analytics/*' }, source: 'network' },
// General: other API calls go through fetch handler
{ condition: { urlPattern: '/api/*' }, source: 'fetch-event' },
]);ServiceWorkerAutoPreload
ServiceWorkerAutoPreload is a Chrome feature that automatically starts a preload request in parallel with service worker startup for navigation requests. It requires no code changes -- the browser handles it automatically.
When enabled, the fetch event handler receives the preloaded response via event.preloadResponse:
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.mode === 'navigate') {
event.respondWith(
(async () => {
const preloadResponse = await event.preloadResponse;
if (preloadResponse) {
return preloadResponse;
}
const cachedResponse = await caches.match(event.request);
return cachedResponse || fetch(event.request);
})(),
);
}
});ServiceWorkerAutoPreload differs from manual Navigation Preload (navigationPreload.enable()) in that it requires zero configuration. However, manually enabling Navigation Preload remains necessary for broader browser support (Firefox 99+, Safari 15.4+).
Static Routing vs Navigation Preload
| Feature | Static Routing | Navigation Preload |
|---|---|---|
| Browser support | Chrome 123+ | Chrome 59+, Firefox 99+, Safari 15.4+ |
| Configuration | Install event rules | Activate event enable |
| SW startup | Can bypass entirely | Runs in parallel |
| Response handling | No fetch event for bypassed | Fetch event receives preload |
| Flexibility | URL patterns, methods | Navigation requests only |
Use static routing for known routes that never need fetch handler logic. Use Navigation Preload for navigations that need SW processing but benefit from parallel network requests.