JavaScript Service Workers and PWA Basics: Offline Caching, Background Tasks, Push

Link copied
JavaScript Service Workers and PWA Basics: Offline Caching, Background Tasks, Push

JavaScript Service Workers and PWA Basics: Offline Caching, Background Tasks, Push

JS Tutorial Module 11: Beyond DOM Lesson 11.4

A Service Worker is a script that runs in the background, separate from the page, that can intercept network requests and respond with cached data. It's what makes a website work offline, what powers push notifications on the web, and what turns a site into an installable PWA (Progressive Web App).

This lesson covers what Service Workers are, how they differ from Web Workers (Lesson 10.4), the registration and lifecycle, the caching strategies you'll actually use, and the manifest file that completes the PWA story.

Service Worker vs Web Worker #

Both run JavaScript off the main thread. Beyond that they're very different:

Web Worker Service Worker
Lifetime Tied to its page Background, persistent
Communication postMessage with the page Network interception + postMessage
Network access Can fetch Can intercept and respond to fetch
Scope One worker per script load One per origin (or scope path)
Purpose Heavy computation Offline, caching, push, background sync
Sleeps when idle No Yes — woken up by events

A Service Worker is event-driven and persistent. It sleeps when nothing's happening, wakes up when the page makes a fetch (or a push arrives), runs your code, then sleeps again. Different mental model entirely.

Registering a Service Worker #

// main.js
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js', { scope: '/' });
}
// sw.js
self.addEventListener('install', (e) => {
  console.log('SW installing');
});

self.addEventListener('activate', (e) => {
  console.log('SW active');
});

self.addEventListener('fetch', (e) => {
  // intercept network requests here
});

Three key requirements:

  • HTTPS only (or localhost for dev). No service workers on plain HTTP.
  • Scope — the SW controls pages under its path. /sw.js controls everything; /app/sw.js only controls /app/*.
  • The SW file must be top-level on its scope. You can't register /static/sw.js to control / — the scope can't be broader than the file's location.

The lifecycle #

Three phases the browser walks the SW through:

1. Install #

Fires once, the first time the SW is registered (or whenever the SW file content changes — byte-for-byte).

self.addEventListener('install', (e) => {
  e.waitUntil(
    caches.open('v1').then(cache => cache.addAll([
      '/',
      '/styles.css',
      '/main.js',
      '/logo.png',
    ]))
  );
});

e.waitUntil(promise) keeps the install phase alive until the promise resolves. Used to pre-cache assets.

2. Activate #

Fires after install, when the SW takes control of clients (pages). The right place to clean up old caches.

self.addEventListener('activate', (e) => {
  e.waitUntil(
    caches.keys().then(keys =>
      Promise.all(keys.filter(k => k !== 'v1').map(k => caches.delete(k)))
    )
  );
});

This pattern — name your cache v1, v2, etc., delete anything that's not the current version — is the standard cache-versioning approach.

3. Idle → fetch / push / sync #

After activate, the SW sleeps. It wakes up on events:

  • fetch — every network request from controlled pages
  • push — server-sent push notification
  • sync — background sync (queued retry of failed requests)
  • message — postMessage from a page
self.addEventListener('fetch', (e) => {
  // your caching strategy here
});

The Cache API #

The storage layer most Service Workers use. Different from IndexedDB — Cache is specifically for Request/Response pairs.

const cache = await caches.open('v1');
await cache.put(request, response);
const response = await cache.match(request);
await cache.delete(request);
const keys = await cache.keys();

A Response can be used only once — to put it in the cache and serve it to the page, you .clone():

const response = await fetch(request);
const forCache = response.clone();
cache.put(request, forCache);
return response;

Caching strategies #

Five standard strategies. Pick based on the kind of data:

1. Cache-first (for static assets) #

Serve from cache; only hit the network if not cached.

self.addEventListener('fetch', (e) => {
  e.respondWith(
    caches.match(e.request).then(cached => cached || fetch(e.request))
  );
});

Use for CSS, JS, fonts, images — anything that doesn't change without a new SW version.

2. Network-first (for HTML pages) #

Try the network; fall back to cache on failure.

self.addEventListener('fetch', (e) => {
  e.respondWith(
    fetch(e.request)
      .then(response => {
        caches.open('v1').then(cache => cache.put(e.request, response.clone()));
        return response;
      })
      .catch(() => caches.match(e.request))
  );
});

Fresh when online; offline-capable as fallback.

3. Stale-while-revalidate (the sweet spot) #

Serve from cache immediately; update the cache in the background. Next request gets the fresh version.

self.addEventListener('fetch', (e) => {
  e.respondWith((async () => {
    const cache = await caches.open('v1');
    const cached = await cache.match(e.request);
    const fetchPromise = fetch(e.request).then(response => {
      cache.put(e.request, response.clone());
      return response;
    });
    return cached || fetchPromise;
  })());
});

Fastest possible response (cached) plus fresh content next time. The default choice for most resources.

4. Cache-only (no network ever) #

For assets you absolutely have ahead of time. Rare.

5. Network-only #

No caching. Useful for analytics, mutating API calls — anything that shouldn't be cached.

self.addEventListener('fetch', (e) => {
  if (e.request.url.includes('/api/')) {
    e.respondWith(fetch(e.request));
    return;
  }
  // ... other strategies for static assets
});

Routing strategies by URL #

Most real SWs combine strategies based on what's being fetched:

self.addEventListener('fetch', (e) => {
  const url = new URL(e.request.url);
  if (url.pathname.startsWith('/api/')) {
    e.respondWith(networkOnly(e.request));
  } else if (url.pathname.match(/\.(jpg|png|svg|webp)$/)) {
    e.respondWith(cacheFirst(e.request));
  } else {
    e.respondWith(staleWhileRevalidate(e.request));
  }
});

For anything beyond toy examples, use Workbox — Google's library that wraps these strategies with one-liners:

import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst, NetworkOnly } from 'workbox-strategies';

registerRoute(/\/api\//,             new NetworkOnly());
registerRoute(/\.(jpg|png|svg)$/,    new CacheFirst());
registerRoute(({ request }) => request.mode === 'navigate', new StaleWhileRevalidate());

Workbox handles cache versioning, expiration, the install/activate boilerplate, and a dozen edge cases. For production PWAs, it's the default.

The manifest — making it installable #

A PWA needs two things to be installable:

  1. A registered Service Worker
  2. A web app manifest linked from the page
<link rel="manifest" href="/manifest.webmanifest">
{
  "name": "Code-JS",
  "short_name": "Code-JS",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0f0a1f",
  "theme_color": "#14b8a6",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

With the manifest plus a SW, browsers show an "Install" prompt to the user. Once installed, the site runs in a standalone window (no browser chrome) and gets a system app icon.

Push notifications #

Service Workers can receive push messages from a server even when the page is closed:

// sw.js
self.addEventListener('push', (e) => {
  const data = e.data.json();
  e.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icon-192.png',
      badge: '/badge-72.png',
    })
  );
});

self.addEventListener('notificationclick', (e) => {
  e.notification.close();
  e.waitUntil(clients.openWindow(e.notification.data.url));
});

Server side, you push via the Web Push protocol (libraries: web-push for Node). Permission must be requested from the user first via Notification.requestPermission().

Full push setup is a multi-step affair (VAPID keys, subscription, server endpoint) — beyond the scope of this lesson. Knowing the SW is the receiver is the key piece.

Background sync #

For offline-first apps, you can queue failed requests and retry when the network is back:

// In the page
await registration.sync.register('sync-comments');

// In sw.js
self.addEventListener('sync', (e) => {
  if (e.tag === 'sync-comments') {
    e.waitUntil(retryPendingComments());
  }
});

Browser support is patchy; Workbox provides a more reliable Background Sync queue.

Debugging #

Chrome and Firefox both have Application/Storage panels in DevTools:

  • See the registered SW, its version, its status
  • Manually unregister, update on reload, bypass for inspection
  • Browse the Cache API contents
  • Trigger push and sync events for testing

During development, enable "Update on reload" in DevTools so SW changes apply immediately. In production, SWs only update on a periodic check (every 24 hours, plus on every page navigation if a new version is available).

A summary #

  • Service Workers are persistent background scripts that intercept network requests.
  • Lifecycle: install → activate → idle, wakes on events.
  • Cache API stores Request/Response pairs. Different from IndexedDB.
  • Five strategies: cache-first, network-first, stale-while-revalidate, cache-only, network-only.
  • Route by URL pattern — different strategies for different resources.
  • Use Workbox for production — it handles the cache versioning and lifecycle complexity.
  • Manifest + SW = installable PWA.
  • Push notifications and background sync are SW-only capabilities.

What's next #

That completes Module 11 — and the full Modern JavaScript Tutorial at 57 lessons across 11 modules. You now have a complete-as-it-can-be JavaScript reference path covering the language, the standard library, regex, advanced JS features, the DOM, and every major beyond-DOM browser API.

Try it yourself #

The smallest useful Service Worker — a 10-line cache-first for everything:

YouWill this code make my site work offline?
self.addEventListener('install', e => {
e.waitUntil(caches.open('v1').then(c =>
c.addAll(['/', '/styles.css', '/app.js'])
));
});
self.addEventListener('fetch', e => {
e.respondWith(
caches.match(e.request).then(cached => cached || fetch(e.request))
);
});
ClaudeYes — for the three URLs in addAll.

Anything else (an image you didn’t pre-cache, an API call) falls through to fetch(e.request), which fails when offline. To handle more, you’d add: a runtime cache for newly-fetched resources, a fallback HTML page for navigation failures, and probably a stale-while-revalidate strategy for the HTML itself.

That’s the trade-off: this works for the three URLs you pre-cache. Real offline-first apps build the strategy out per resource type.

You now have the full kit — 11 modules, 57 lessons. Everything you'd reach for in modern web JavaScript, covered.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email address will not be published. Required fields are marked *