Exploring Service Workers with React: From Offline to Push Notifications

Cover Image for Exploring Service Workers with React: From Offline to Push Notifications
Rahul M. Juliato
Rahul M. Juliato
#react#service-workers# pwa# javascript# performance# frontend

Hey everyone! Following up on my last post about Web Workers with React, I'm excited to dive into another powerful browser API: Service Workers.

In this guide, we'll explore how Service Workers can make our React applications more resilient and engaging. We'll start with a basic online-only app and progressively enhance it with offline capabilities, background data synchronization, and real push notifications.

The complete source code for each step is available in my GitHub repository.

Part 1: The Problem - The App That Needs a Connection

service-workers-react-demo-09

Let's start with a standard React application. It's a simple app that fetches a random joke from an API and displays it.

// service-workers-demos/01-react-no-sw/src/App.jsx
function App() {
  const [quote, setQuote] = useState("Loading...");

  useEffect(() => {
    fetch("https://official-joke-api.appspot.com/random_joke")
      .then((r) => r.json())
      .then((j) => setQuote(`${j?.setup} - ${j?.punchline}`))
      .catch(() => setQuote("❌ Error fetching quote."));
  }, []);

  return (
    <>
      <h1>App1 - Common React App</h1>
      <div className="card">
        <p>{quote}</p>
      </div>
      <button onClick={() => window.location.reload()}>Reload Page</button>
    </>
  );
}

A quick note: you might notice we're fetching data directly inside useEffect. For this guide, I've simplified some patterns to keep the focus squarely on the Service Worker implementations themselves, avoiding more complex state management or data-fetching abstractions. Now, back to business.

This fetch works perfectly fine... as long as you're online. If you open your browser's DevTools, go to the "Network" tab, and simulate being offline, the app breaks completely upon reload. This is because it can't fetch its own assets or the joke from the API.

Hello dinosaur friend:

service-workers-react-demo-10

Part 2: The Solution - A Manual Service Worker

service-workers-react-demo-01

To solve the offline problem, we introduce a Service Worker. It acts as a proxy between our application and the network, allowing us to cache resources and serve them even when there's no internet connection.

First, we need to register the Service Worker. We do this in our application's entry point.

// service-workers-demos/02-react-sw-manual/src/sw-register.js
export function registerSW() {
  if ("serviceWorker" in navigator) {
    window.addEventListener("load", () => {
      navigator.serviceWorker
        .register("/service-worker.js")
        .then((reg) => console.log(" 🟒 Registered SW:", reg))
        .catch((err) => console.error(" πŸ”΄ Error registering SW:", err));
    });
  }
}

Next, we define the Service Worker's behavior. In the install event, we cache our application's shell, the essential files needed for it to run. In the activate event, we clean up old caches.

// service-workers-demos/02-react-sw-manual/public/service-worker.js
const CACHE_NAME = "app-cache-v1";
const URLS_TO_PRECACHE = ["/", "vite.svg"];

self.addEventListener("install", (event) => {
  console.log("πŸ”§ [SW] install");
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(URLS_TO_PRECACHE)),
  );
  self.skipWaiting();
});

self.addEventListener("activate", (event) => {
  console.log("πŸ”§ [SW] activate");
  event.waitUntil(
    caches
      .keys()
      .then((names) =>
        Promise.all(
          names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n)),
        ),
      ),
  );
  self.clients.claim();
});

❗Important! Be friends with Dev Tools, you'll need them a lot!

With Dev Tools, you can view the Service Worker's status, inspect the cache, and perform manual resets or overrides. When working with Service Workers, you'll often need to manually clear loaded services and caches. Also, when following the examples in this post, make sure to start with a clean cache and no registered workers before testing.

Service Worker DevTools here:

service-workers-react-demo-02

Cache DevTools here:

service-workers-react-demo-03

Now back to business

The real magic happens in the fetch event. Here, we intercept every network request and implement a "cache-first" strategy.

// service-workers-demos/02-react-sw-manual/public/service-worker.js
self.addEventListener("fetch", (event) => {
  const req = event.request;
  event.respondWith(
    caches.match(req).then((cachedResp) => {
      if (cachedResp) return cachedResp; // Return from cache if found
      return fetch(req) // Otherwise, fetch from network
        .then((networkResp) => {
          // If the request is for our own origin, cache the response
          if (
            req.method === "GET" &&
            networkResp &&
            networkResp.status === 200 &&
            req.url.startsWith(self.location.origin)
          ) {
            const copy = networkResp.clone();
            caches.open(CACHE_NAME).then((cache) => cache.put(req, copy));
          }
          return networkResp;
        })
        .catch(() => {
          // If network fails, provide a fallback
          if (req.headers.get("accept").includes("text/html")) {
            return caches.match("/index.html");
          }
        });
    }),
  );
});

With this in place, our app now works offline! The app shell is served from the cache, and while the external API call might fail, the app itself remains functional.

Try changing the network status and then refresh the page: service-workers-react-demo-04

This should be our new result! service-workers-react-demo-05

You might notice how everything is served from the Network tab:

service-workers-react-demo-06

Part 3: Going Further with Background Sync and Push

Service Workers unlock more than just offline caching.

Background Sync

What if a user tries to submit a form while offline? With Background Sync, we can defer the action until the connection is restored.

In our app, we register a sync event:

// service-workers-demos/02-react-sw-manual/src/App.jsx
async function scheduleSendData() {
  if ("serviceWorker" in navigator && "SyncManager" in window) {
    const reg = await navigator.serviceWorker.ready;
    try {
      await reg.sync.register("send-form");
      alert("Scheduled sending. Will be executed when back online.");
    } catch (err) {
      alert("Error scheduling: " + err);
    }
  }
}

The Service Worker listens for this event and executes the task when the network is available.

// service-workers-demos/02-react-sw-manual/public/service-worker.js
self.addEventListener("sync", (event) => {
  if (event.tag === "send-form") {
    event.waitUntil(sendPendingData());
  }
});

function sendPendingData() {
  return fetch("/api/save", {
    method: "POST",
    body: JSON.stringify({ msg: "Example data!!!!" }),
    headers: { "Content-Type": "application/json" },
  });
}

service-workers-react-demo-08

Push Notifications

Service Workers can also receive push notifications from a server, even when the app's tab is closed. First, the app must request permission.

// service-workers-demos/02-react-sw-manual/src/App.jsx
function askPermission() {
  if ("Notification" in window) {
    Notification.requestPermission().then((perm) => {
      if (perm === "granted") alert("Notifications permission granted!");
      else alert("Notifications permissions denied.");
    });
  }
}

Then, the Service Worker handles the push event to display the notification.

// service-workers-demos/02-react-sw-manual/public/service-worker.js
self.addEventListener("push", (event) => {
  let title = "Default title";
  let body = "Hello from fake push";

  if (event.data) {
    try {
      const parsed = JSON.parse(event.data.text());
      title = parsed.title || title;
      body = parsed.body || body;
    } catch {
      body = event.data.text();
    }
  }
  const options = { body, icon: "/vite.svg" };
  event.waitUntil(self.registration.showNotification(title, options));
});

You can now simulate a push event from the browser's DevTools: service-workers-react-demo-02

Note that not only you need permission you grant in your browser, but your OS should also grant permission. This means you might need to mess around giving your browser permission to send notifications before this step works.

This means the showing behaviour is also controlled by your OS, there's no guarantee it will work the same across every browser/OS combination, implement your icons, or even show the full message. This is what I get on macOS in my machine:

service-workers-react-demo-07

Part 4: Simplifying with Workbox

Writing a Service Worker from scratch can be verbose and error-prone. Workbox, a library from Google, simplifies this by providing production-ready strategies for caching and background tasks.

Here's how the fetch logic looks with Workbox:

// service-workers-demos/03-react-sw-workbox/public/sw.js
/* global workbox importScripts */
importScripts(
  "https://storage.googleapis.com/workbox-cdn/releases/6.5.4/workbox-sw.js",
);

// SPA navigation route (NetworkFirst)
workbox.routing.registerRoute(
  ({ request }) => request.mode === "navigate",
  new workbox.strategies.NetworkFirst({ cacheName: "html-shell" }),
);

// Jokes API: StaleWhileRevalidate
workbox.routing.registerRoute(
  ({ url }) => url.origin === "https://official-joke-api.appspot.com",
  new workbox.strategies.StaleWhileRevalidate({ cacheName: "api-jokes-cache" }),
);

// Static assets: StaleWhileRevalidate
workbox.routing.registerRoute(
  ({ request }) => ["style", "script", "worker"].includes(request.destination),
  new workbox.strategies.StaleWhileRevalidate({
    cacheName: "static-resources",
  }),
);

// Background Sync with a plugin
const bgSyncPlugin = new workbox.backgroundSync.BackgroundSyncPlugin(
  "form-queue",
);
workbox.routing.registerRoute(
  ({ url, request }) => request.method === "POST",
  new workbox.strategies.NetworkOnly({ plugins: [bgSyncPlugin] }),
  "POST",
);

Workbox makes the code much more declarative and less boilerplate, while providing robust, battle-tested caching strategies.

Part 5: Real Push Notifications with VAPID

To send real push notifications, our server needs to identify itself to the browser's push service. We use the VAPID protocol for this.

The flow is:

βž– The client requests a PushSubscription and sends it to our server.

βž– The server stores this subscription.

βž– The server uses the web-push library to send a message to the push service, which then delivers it to the correct Service Worker.

Here's the client-side code to subscribe:

// service-workers-demos/04-push-example/client/src/App.jsx
const PUBLIC_VAPID_KEY = "same as server"; // This should be your public VAPID key

async function subscribeUser() {
  const reg = await navigator.serviceWorker.register("/sw-push.js");
  const sub = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY),
  });

  // Send subscription to the server
  await fetch("http://localhost:4000/subscribe", {
    method: "POST",
    body: JSON.stringify(sub),
    headers: { "Content-Type": "application/json" },
  });
}

And the Node.js server that sends the push:

// service-workers-demos/04-push-example/server/server.js
const webpush = require("web-push");
// ... configure VAPID keys ...

let subscriptions = [];

app.post("/subscribe", (req, res) => {
  const sub = req.body;
  subscriptions.push(sub);
  res.status(201).json({ ok: true });
});

// Send a push to all subscribers
function sendToAll(payload) {
  subscriptions.forEach((sub) => {
    webpush.sendNotification(sub, JSON.stringify(payload)).catch(console.error);
  });
}

This setup provides a complete end-to-end push notification system.

Conclusion

Service Workers are a cornerstone of modern web development, enabling Progressive Web Apps (PWAs) that are reliable, fast, and engaging. We've seen how they can:

βž– Provide a seamless offline experience by caching resources.

βž– Defer actions until the network is available with Background Sync.

βž– Re-engage users with Push Notifications.

While manual implementation is possible, libraries like Workbox are highly recommended to simplify development and avoid common pitfalls.

A Quick Comparison: Web, Shared, and Service Workers

To recap, here's how the different types of workers compare:

| Feature / Capability            | Web Worker              | Shared Worker                    | Service Worker                               |
| ------------------------------- | ----------------------- | -------------------------------- | -------------------------------------------- |
| Scope                           | Single page/tab         | Shared across tabs (same origin) | Global (site-wide, independent of tabs)      |
| Shared across tabs              | No                      | Yes                              | Yes                                          |
| Communication                   | `postMessage` (1:1)     | `port.postMessage` (many:1)      | `postMessage`, `fetch`, Push API, etc.       |
| Persists after tab closes       | No                      | No                               | Yes (managed by browser)                     |
| Use case                        | Offload CPU-heavy tasks | Coordinate logic across tabs     | Background sync, caching, push notifications |
| DOM access                      | No                      | No                               | No                                           |
| Network interception            | No                      | No                               | Yes (`fetch` interception)                   |
| Requires secure context (HTTPS) | No                      | No                               | Yes (HTTPS required)                         |

I hope this guide has been a helpful introduction to using Service Workers with React! Feel free to explore the code on GitHub and experiment with these powerful features.