Service Worker Project — Part 2: Writing the Service Worker 🛠️
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
installoractivateagain
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:
- 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.
- 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
| Event | When it fires | What to do here |
|---|---|---|
install | First time this SW version loads | Call skipWaiting(), pre-cache static assets |
activate | After install, once old SW is gone | Clean 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:
- User lands on your site
- Service worker starts downloading assets into the cache
- User immediately leaves
- Browser kills the service worker
- 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 waitUntil | With waitUntil |
|---|---|
| Browser may kill SW mid-task | Browser keeps SW alive until promise resolves |
| Cache could be left in inconsistent state | Work completes fully before shutdown |
| Silent failures during activation | Predictable, 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.
clientsis 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 tab → Service Workers.
Key things visible here:
- 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.
- Start / Stop buttons — manually simulate the browser killing and restarting the service worker. Useful for testing restart behaviour without closing the tab.
- Three checkboxes — Kyle's honest take on each:
| Checkbox | Kyle's Take |
|---|---|
| Bypass for network | Dangerous — easy to leave on and forget, wasted 3 days on this once |
| Update on reload | Likely now the default behaviour, probably not needed |
| Offline | Never 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) activatedThen 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 startingNotice: 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 restart2. 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:
isOnline—navigator.onLineis available in the SW, but theonline/offlineevents are not. So the page monitors connectivity and reports it.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
svcWorkerinstead 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
| Direction | Method | When |
|---|---|---|
| Page → SW | svcWorker.postMessage(msg) | Proactive status updates |
| Page → SW port | evt.ports[0].postMessage(msg) | Replying to a SW request |
| SW → Page | postMessage via MessageChannel | Requesting status update |
| Listening on page | navigator.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.