Service Worker Project — Part 1: Setup & Registration 🏗️

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

The Project: A Simple Blog Site

The site Kyle built for this project is a basic blog — a few static pages, some images, a CSS file, and the ability to write and save blog posts. Nothing fancy. That's intentional.

The point isn't the app — the point is what happens to it when the network disappears.


The Two Offline Scenarios We're Solving For 🎯

This was the most clarifying thing from this lecture. "Being offline" isn't just one thing — there are actually two distinct failure scenarios, and they need to be handled slightly differently in code:

  1. The user's device is disconnected — no internet at all, airplane mode, dead signal
  2. The user is online but your server is unreachable — bad routing on the internet, your server crashed, deployment went wrong

In both cases, the user currently sees the same thing: a blank white screen of death.

"They really shouldn't be presented with the blank screen of death. They really should continue to see the site — at least as it was the last time they loaded it."

That's the goal. Not perfection. Just significantly better than nothing.


Simulating Offline in Chrome DevTools 🛠️

Two ways to test offline behaviour during development:

  1. Network tab → Offline checkbox — simulates the device being fully disconnected. A small icon appears in the browser to confirm it's active. Clicking any page now → white screen of death.
  2. Kill the server (Ctrl+C) — simulates the server being down while the user's internet is still working. Produces a slightly different Chrome error page.

Both scenarios will be tested throughout the exercises, and the service worker needs to handle both.

Kyle noted Chrome has two different error pages for these two scenarios — which has always confused him. Either way, the goal is to make neither of them appear.


The Core Goal 💡

Add a service worker to this plain blog site — not a PWA, not an app — just a regular website — so that:

  1. Pages the user has already visited continue to load when offline
  2. The site doesn't die if the server goes down
  3. The experience degrades gracefully rather than failing completely

"It's not 100%, but we can get a lot better than what it currently is."

That framing matters. The goal isn't magic — it's meaningful improvement over the default broken experience.


Next up: Detecting Offline Status — how to know in JavaScript when the network has gone away.


Lecture 3: Detecting Offline Status 📡

Why Detect Offline Status at All?

Before touching the service worker, Kyle starts with something simpler — telling the user they're offline. The reasoning is pure UX:

"Users don't want to click a button and then be told something failed. They want to know in advance that something might not work."

A small visual indicator — not blaring, just present — sets the right expectation before the user does anything.


The Two Files We're Working In

Inside web/js/ there are two key files for this project:

  1. blog.js — the main page JavaScript, handles UI and user interactions
  2. sw.js — the service worker file (we'll get to this later)

Offline detection lives in blog.js.


Checking Online Status on Page Load

The browser exposes online status via navigator.onLine (capital L). Here's how to read it safely on page load:

let isOnline = ('onLine' in navigator) ? navigator.onLine : true;

Two things happening here:

  1. Feature detection first — check if onLine even exists in navigator before using it. Most modern browsers support it, but it's good practice.
  2. Default to true — if the API isn't available, assume the user is online. Safe assumption since the page just loaded.

Then immediately act on that initial state:

if (!isOnline) { offlineIcon.classList.remove('hidden'); }

The offline icon exists in the HTML already — it's just hidden with a hidden CSS class. Removing that class makes it visible.


Listening for Network Changes

Don't poll navigator.onLine on a timer — the browser fires events when connectivity changes. Listen for both:

window.addEventListener('online', function() { offlineIcon.classList.add('hidden'); // hide the icon isOnline = true; }); window.addEventListener('offline', function() { offlineIcon.classList.remove('hidden'); // show the icon isOnline = false; });

The online and offline events fire on the window object whenever the browser detects a connectivity change. Clean, event-driven, no polling needed.


Important Limitation to Know ⚠️

navigator.onLine and the online/offline events only detect whether the user's device has a network connection. They do NOT tell you:

  • Whether your specific server is reachable
  • Whether there's a routing problem between the user and your server
  • Whether your server is down

For that level of detection you'd need something like a persistent WebSocket — if it drops, show the icon. Kyle's take: that's overkill for a static blog. Fine for a live stock ticker app, not necessary here.

"We're just trying to make a good faith effort to let the user know — you're in offline mode, expect things may not work normally."


The Full Offline Detection Logic

// Initial state on page load let isOnline = ('onLine' in navigator) ? navigator.onLine : true; if (!isOnline) { offlineIcon.classList.remove('hidden'); } // React to changes window.addEventListener('online', function() { offlineIcon.classList.add('hidden'); isOnline = true; }); window.addEventListener('offline', function() { offlineIcon.classList.remove('hidden'); isOnline = false; });

Test it in Chrome DevTools → Network tab → toggle Offline → the icon appears and disappears in real time. ✅


Next up: Register & Install a Service Worker — the first step to getting the service worker running on the page.


Lecture 4: Register & Install a Service Worker 🔧

The Key Difference from Web Workers

With a regular web worker, you get the worker instance back immediately:

worker = new Worker('/js/worker.js'); // you have it right away

With a service worker, you get back a registration object first — and then access the actual worker through that, depending on what lifecycle state it's in. More on lifecycle later, but the distinction matters from the start.


The Variables You Need

Three variables to set up at the top of blog.js:

let swRegistration; // stores the registration object let isServiceWorkerSupported = ('serviceWorker' in navigator); // feature detection

Feature detect before using — same pattern as navigator.onLine. If navigator.serviceWorker doesn't exist, the browser doesn't support it and you skip the whole thing.


Registering the Service Worker

The registration happens inside an async function called initServiceWorker:

async function initServiceWorker() { swRegistration = await navigator.serviceWorker.register('/sw.js', { updateViaCache: false }); } initServiceWorker().catch(console.error);

Two parameters to register():

  1. The URL of the service worker file/sw.js (not /js/sw.js — more on this below)
  2. Configuration objectupdateViaCache: false tells the browser to always check the network for an updated service worker file, never serve it from cache

The Scope Problem — Why the URL Matters 🎯

This is one of the most important gotchas in the whole lecture.

A service worker's scope is determined by where its file is located. If you register it from /js/sw.js, it can only intercept requests for files under /js/ — completely useless for handling your homepage, CSS, or anything else at the root.

You want the service worker to cover the entire site, so it needs to be registered from the root: /sw.js.

But you probably don't want to actually store sw.js at the root — keeping it in /js/ alongside other scripts makes more sense organizationally.

The solution: a server-side URL rewrite. Kyle already set this up on the exercise server — a request to /sw.js internally loads /js/sw.js. If you set this up on your own project, you'd configure the same kind of redirect in your server or .htaccess.

"If you locate your service worker anywhere other than the root, you probably want to do some sort of URL redirect on your server to make that happen."


The Full Registration Code

let swRegistration; let isServiceWorkerSupported = ('serviceWorker' in navigator); async function initServiceWorker() { swRegistration = await navigator.serviceWorker.register('/sw.js', { updateViaCache: false }); } // Call it, catch any errors so they show in the console initServiceWorker().catch(console.error);

At this point the service worker file is registered — the browser will fetch sw.js, install it, and hand back the registration object. What happens next depends on the service worker's lifecycle, which is covered in the upcoming lectures.


Next up: Service Worker Access — how to get a reference to the actual active service worker through the registration object.


Lecture 5: Service Worker Access 🔑

The Three States a Service Worker Can Be In

After registration, the service worker isn't immediately "ready" — it moves through a lifecycle. At any given moment it's in exactly one of three states, all accessible through the registration object:

swRegistration.installing // being installed for the first time swRegistration.waiting // installed but waiting for the old one to die swRegistration.active // fully in control of the page

Grab whichever one is currently active:

svcWorker = swRegistration.installing || swRegistration.waiting || swRegistration.active;

Why Three States? The Lifecycle Explained 🔄

This is the part that took me the most time to wrap my head around — but it makes sense once you see why.

Installing — the first time this exact service worker file is loaded. Importantly, even a single changed code comment counts as a "new" service worker that must go through the full lifecycle again.

Waiting — this is where it gets interesting. You can never have two service workers active at the same time. So when a new version is installed while the old one is still running:

  1. New worker installs → enters waiting state
  2. Sits there until the old worker's page lifetime ends
  3. Old worker dies → new worker immediately activates

The catch: a simple page refresh isn't enough to kill the old worker. The user has to actually navigate to a new page before the old one is considered done.

This default exists to prevent mismatches — if the page was loaded by the old service worker, running new service worker code against it could cause weird conflicts.

Active — fully in control, intercepting requests, running normally.


skipWaiting — Skipping the Queue ⏭️

The waiting behaviour can be annoying in practice. Imagine a user who loaded your page a while ago. They come back, a new service worker is waiting, but nothing looks different — until they click a link. Then everything changes at once. Confusing.

The fix is calling skipWaiting() inside the service worker during installation. It tells the old worker to die immediately so the new one can take over:

// Inside sw.js — called during the install event self.skipWaiting();

Kyle's personal preference: always use skipWaiting(). He understands why the two-stage waiting exists, but the immediate takeover is almost always the better user experience.


Listening for Controller Changes

When a new service worker takes over (either naturally or via skipWaiting), a controllerchange event fires. Listen for it to update your reference to the active worker:

navigator.serviceWorker.addEventListener('controllerchange', function() { svcWorker = navigator.serviceWorker.controller; });

navigator.serviceWorker.controller always points to the currently active service worker controlling the page.


updateViaCache: 'none' — Not false

A small but important correction from the previous lecture. The updateViaCache option takes a string, not a boolean:

swRegistration = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' // ✅ correct — string 'none', not false });

This tells the browser to never serve the service worker file itself from cache — always fetch a fresh copy to check for updates.


Do You Need to Call register() Every Time?

Technically no — once a service worker is registered and unchanged, calling register() again is essentially a no-op. The browser checks for updates unconditionally on every page load anyway (since Chrome 68+, it ignores cache headers for the service worker file itself).

But Kyle always calls it regardless:

"It's much simpler in the logic to just write this code every time. And it's essentially a no-op in any other case — but it's a good idea to make sure it's definitely there."


The Full Access Pattern

let svcWorker; let swRegistration; let isServiceWorkerSupported = ('serviceWorker' in navigator); async function initServiceWorker() { swRegistration = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }); // Grab whatever state the worker is currently in svcWorker = swRegistration.installing || swRegistration.waiting || swRegistration.active; // Update reference when a new worker takes over navigator.serviceWorker.addEventListener('controllerchange', function() { svcWorker = navigator.serviceWorker.controller; }); } initServiceWorker().catch(console.error);

Next up: Creating a Service Worker — writing the actual sw.js file and handling the install event.