Service Worker Project — Part 2: Writing the Service Worker 🛠️

March 5, 202613 min read
service workerPWAofflinecachingweb workersprogressive web appsbackground sync

Lecture 6: Creating a Service Worker 🛠️

When Does the Service Worker Code Run?

This is the first thing to understand before writing a single line in sw.js:

  • On first registration — the global scope code runs immediately
  • On restart after being killed — the global scope runs again, but it does NOT go through install or activate again

The browser can and will kill an idle service worker (especially if the tab has been in the background for hours or days). When the page becomes active again, the browser restarts the service worker — but skips straight to running the global code, bypassing the lifecycle events.

Be careful what you store in the global scope — it gets re-initialized every time the service worker restarts.


The Version Variable 🔢

The first thing to set up in sw.js is a version number:

const version = 1;

Every time you change this number, the browser sees a completely new service worker and puts it through the full install/activate cycle. This is the mechanism for cache-busting — bump the version, blow away the old cache, prime everything fresh.

Two strategies for versioning:

  1. Atomic updates (Kyle's preference) — everything is version 1, then everything becomes version 2. Users get the full updated site all at once. Costs a bit of extra bandwidth on updates, but everything stays in sync.
  2. Incremental updates — update resources individually as they change. Less bandwidth, but more complex to manage and risks mismatches between old and new files.

Even a changed code comment is enough to trigger a new service worker install. The version variable just makes it intentional and trackable.


The main() Function — Runs on Every Start

Kyle sets up a main() function that runs every time the service worker starts — whether it's a fresh install or a restart after being killed:

async function main() { console.log(`Service Worker (v${version}) is starting`); } main().catch(console.error);

Always catch errors on async functions in the service worker. Mistakes here fail silently — no error will surface unless you explicitly catch and log it. Kyle's done the hair-pulling debugging session that results from forgetting this.


The Three Event Listeners

The service worker listens for two lifecycle events:

self.addEventListener('install', onInstall); self.addEventListener('activate', onActivate);

And the two handler functions:

async function onInstall(event) { console.log(`Service Worker (v${version}) installed`); self.skipWaiting(); // take over immediately, don't wait } async function onActivate(event) { console.log(`Service Worker (v${version}) activated`); }

self.skipWaiting() — Why It's Called in onInstall

After the install event fires, the default behaviour is to enter the waiting state — sitting idle until the old service worker's page is gone.

Calling self.skipWaiting() inside onInstall tells the browser:

"Don't wait. Kill the old service worker and activate me immediately."

This means the new service worker takes control as fast as possible, serving updated resources sooner. The trade-off: users might briefly see old content if the page was already loaded by the previous worker. For most sites this is acceptable — and far better than users being stuck on an old version until their next navigation.


What Goes in Each Lifecycle Event

EventWhen it firesWhat to do here
installFirst time this SW version loadsCall skipWaiting(), pre-cache static assets
activateAfter install, once old SW is goneClean up old caches
main()Every start (install AND restart)Initialization logic

The Full sw.js Skeleton So Far

const version = 1; // Runs every time the SW starts (install or restart) async function main() { console.log(`Service Worker (v${version}) is starting`); } main().catch(console.error); // Lifecycle events self.addEventListener('install', onInstall); self.addEventListener('activate', onActivate); async function onInstall(event) { console.log(`Service Worker (v${version}) installed`); self.skipWaiting(); } async function onActivate(event) { console.log(`Service Worker (v${version}) activated`); }

Next up: Keeping the Service Worker Alive — how the browser manages the SW lifecycle and what you can do about it.


Lecture 7: Keeping the Service Worker Alive ⏳

The Problem: The Browser Can Kill Your Worker Mid-Task

The browser manages the service worker lifecycle — and that means it can decide to shut it down at any time. This becomes a real problem during onActivate, where you'll be doing heavy async work like pre-caching resources.

Imagine this scenario:

  1. User lands on your site
  2. Service worker starts downloading assets into the cache
  3. User immediately leaves
  4. Browser kills the service worker
  5. Cache is now partially filled — inconsistent state

That's exactly what waitUntil is designed to prevent.


event.waitUntil() — Tell the Browser to Wait 🛑

The lifecycle event objects (install, activate) have a special method called waitUntil(). You pass it a promise, and the browser will keep the service worker alive until that promise resolves:

async function onActivate(event) { event.waitUntil(handleActivation()); }

This is a strong request to the browser — not a guarantee. If the work runs for too long, the browser will eventually force-kill it anyway. But it gives you significantly more runway than without it.

"Don't shut my service worker down until I'm done loading up all my cache stuff."


handleActivation() — Where the Real Work Happens

The pattern Kyle uses is to keep onActivate lean and delegate the actual async work to a separate function:

async function onActivate(event) { event.waitUntil(handleActivation()); } async function handleActivation() { // cache priming, cleanup, and other activation work goes here console.log(`Service Worker (v${version}) activated`); }

Why a separate function? Keeps the event handler clean and makes handleActivation independently testable and readable.


The Same Pattern Applies to onInstall

waitUntil isn't only for onActivate — the same pattern applies to onInstall too. Any async work during installation should also be wrapped:

async function onInstall(event) { event.waitUntil(handleInstall()); self.skipWaiting(); } async function handleInstall() { // pre-caching static assets goes here console.log(`Service Worker (v${version}) installed`); }

Updated sw.js With waitUntil Pattern

const version = 1; async function main() { console.log(`Service Worker (v${version}) is starting`); } main().catch(console.error); self.addEventListener('install', onInstall); self.addEventListener('activate', onActivate); async function onInstall(event) { event.waitUntil(handleInstall()); self.skipWaiting(); } async function onActivate(event) { event.waitUntil(handleActivation()); } async function handleInstall() { console.log(`Service Worker (v${version}) installed`); } async function handleActivation() { console.log(`Service Worker (v${version}) activated`); }

Key Rule: Always Use waitUntil for Async Lifecycle Work

Without waitUntilWith waitUntil
Browser may kill SW mid-taskBrowser keeps SW alive until promise resolves
Cache could be left in inconsistent stateWork completes fully before shutdown
Silent failures during activationPredictable, controlled lifecycle

Next up: Inspecting Service Worker Lifecycle — using Chrome DevTools to watch the install, wait, and activate states play out in real time.


Lecture 8: Inspecting Service Worker Lifecycle 🔍

clients.claim() — Taking Control of All Open Tabs

skipWaiting() makes the new service worker activate — but that doesn't automatically mean every open tab knows it's now being controlled by the new worker. Tabs that were loaded under the old service worker are still thinking they're talking to it.

The fix is clients.claim() — called inside handleActivation():

async function handleActivation() { await clients.claim(); console.log(`Service Worker (v${version}) activated`); }

clients.claim() tells the newly activated service worker to immediately take control of all open tabs/pages it's responsible for. This is what fires the controllerchange event back on the page that we listened for in blog.js.

clients is an API that lets you cycle through all connected pages. You could claim only some of them — but Kyle can't think of a reason why you'd ever want to.

The await is important — clients.claim() is async, and you want the log message to fire only after all clients have been claimed. This is also why handleActivation needs to be a separate async function — it produces a clean single promise that event.waitUntil() can accept.


The DevTools Application Tab — Your Service Worker Dashboard 🛠️

This is where you'll spend most of your debugging time. In Chrome DevTools → Application tabService Workers.

Key things visible here:

  1. The installed service worker — shown with a unique ID. If you make changes and don't see the ID changing, the new worker isn't being installed — likely a syntax error.
  2. Start / Stop buttons — manually simulate the browser killing and restarting the service worker. Useful for testing restart behaviour without closing the tab.
  3. Three checkboxes — Kyle's honest take on each:
CheckboxKyle's Take
Bypass for networkDangerous — easy to leave on and forget, wasted 3 days on this once
Update on reloadLikely now the default behaviour, probably not needed
OfflineNever used it — prefers the Network tab for offline simulation

Watching the Lifecycle Play Out 👀

After refreshing the page with the service worker in place, the console shows:

Service Worker (v1) is starting Service Worker (v1) installed Service Worker (v1) activated

Then in the Application tab, click Stop — simulating the browser killing the idle worker. Click Start again — simulating the browser reviving it. Back in the console:

Service Worker (v1) is starting

Notice: installed and activated did not fire again. Only main() ran. This confirms exactly what was explained in Lecture 6 — restart skips the lifecycle events entirely.


Two Critical Things to Know About Service Worker State ⚠️

1. No access to localStorage

Service workers cannot access localStorage. If you need to persist state across restarts:

// ❌ Won't work in a service worker localStorage.setItem('key', 'value'); // ✅ Use IndexedDB instead // Store data here if it needs to survive a SW restart

2. Syntax errors silently discard the new worker

If your updated sw.js has any error, the browser throws it away entirely and keeps the old service worker running. You'll get a console error, but if you miss it you'll spend a long time wondering why your changes aren't taking effect.

"I've had this happen where I've been trying to make a change and I didn't realize there was some syntax error causing it to never get installed."

Always check the Application tab — if the SW's unique ID hasn't changed after a reload, something went wrong with the new install.


Q&A: Why Named Functions Over Arrow Functions?

A student asked why Kyle consistently uses named function declarations instead of arrow functions. His answer is worth keeping:

"Every function in your program has a purpose. If it has a purpose, it has a name. The reader shouldn't have to read the function body to figure out what it does — you should just tell them."

It's a readability choice, not a technical limitation. Named functions communicate intent. handleActivation tells you what it does before you read a single line of it.


The Complete sw.js So Far

const version = 1; async function main() { console.log(`Service Worker (v${version}) is starting`); } main().catch(console.error); self.addEventListener('install', onInstall); self.addEventListener('activate', onActivate); async function onInstall(event) { event.waitUntil(handleInstall()); self.skipWaiting(); } async function onActivate(event) { event.waitUntil(handleActivation()); } async function handleInstall() { console.log(`Service Worker (v${version}) installed`); } async function handleActivation() { await clients.claim(); console.log(`Service Worker (v${version}) activated`); }

Next up: Message Handling in the Client — how the page and service worker communicate with each other.


Lecture 9: Message Handling in the Client 📨

Why Messaging Is More Complex Than Web Workers

With a dedicated web worker, messaging was simple — one page, one worker, postMessage directly. A service worker can control multiple pages simultaneously, so the messaging layer needs to be smarter about who is talking to whom.


Sending Messages to the Service Worker — sendSWMessage()

A utility function in blog.js that handles three possible scenarios:

function sendSWMessage(msg, target) { if (target) { target.postMessage(msg); // send to a specific target (message channel port) } else if (svcWorker) { svcWorker.postMessage(msg); // send to the known service worker reference } else { navigator.serviceWorker.controller.postMessage(msg); // fallback } }

The target parameter will make more sense shortly — it's a specific message channel port, not the service worker object itself.


Listening for Messages from the Service Worker

navigator.serviceWorker.addEventListener('message', onSWMessage); function onSWMessage({ data: msg, ports: [port] }) { if (msg.requestStatusUpdate) { console.log('Received status update request from service worker, responding.'); sendStatusUpdate(port); } }

Note: listen on navigator.serviceWorker, not on navigator.serviceWorker.controller. The controller reference can change; the serviceWorker object is stable.


Why the Service Worker Needs to Ask the Page for Status

The service worker needs two pieces of information it can't reliably get itself:

  1. isOnlinenavigator.onLine is available in the SW, but the online/offline events are not. So the page monitors connectivity and reports it.
  2. isLoggedIn — requires reading cookies. Service workers have no access to cookies at all.

The solution: when the service worker starts (or restarts), it sends a requestStatusUpdate message to the page asking for this data.


sendStatusUpdate() — The Page's Response

function sendStatusUpdate(target) { sendSWMessage({ statusUpdate: { isOnline, isLoggedIn } }, target); }

The message is wrapped in a statusUpdate property so the service worker can easily identify what kind of message it received.


Message Channels — Why evt.ports[0] Is the Target

When the service worker sends a requestStatusUpdate, it creates a MessageChannel — a pair of ports for a dedicated communication line between the SW and a specific page. The port the page should reply on is evt.ports[0].

This is why sendSWMessage accepts a target — instead of posting to the service worker object, you post to that specific port:

function onSWMessage({ data: msg, ports: [port] }) { if (msg.requestStatusUpdate) { sendStatusUpdate(port); // reply on the specific port, not the SW object } }

If you posted back to svcWorker instead of the port, the message might not reach the right listener on the service worker side.


When to Proactively Send Status Updates

Don't wait for the SW to ask — proactively send updates at key moments:

// When a new service worker takes control navigator.serviceWorker.addEventListener('controllerchange', function() { svcWorker = navigator.serviceWorker.controller; sendStatusUpdate(svcWorker); // tell the new SW the current status immediately }); // When connectivity changes window.addEventListener('online', function() { offlineIcon.classList.add('hidden'); isOnline = true; sendStatusUpdate(); // no target — picks up current active SW automatically }); window.addEventListener('offline', function() { offlineIcon.classList.remove('hidden'); isOnline = false; sendStatusUpdate(); });

This keeps the service worker's picture of the world in sync without it having to ask every time.


The Full Messaging Pattern Summary

DirectionMethodWhen
Page → SWsvcWorker.postMessage(msg)Proactive status updates
Page → SW portevt.ports[0].postMessage(msg)Replying to a SW request
SW → PagepostMessage via MessageChannelRequesting status update
Listening on pagenavigator.serviceWorker.addEventListener('message')Always

Next up: Message Handling in the Service Worker — the other side of the conversation, receiving status updates and requesting them on start.