DEV Community

Timevolt
Timevolt

Posted on

Building PWAs That Actually Work Offline

Building PWAs That Actually Work Offline

Quick context (why you're writing this)

Here's the thing: I was proud of the sleek PWA I shipped last quarter — until a user on a spotty train connection complained the app froze after the first tap. I’d assumed “service worker + manifest = offline ready” was enough. Spoiler: it wasn’t. After a couple of frustrating hours digging through logs, I realized I’d missed the most basic piece: a proper caching strategy that actually serves something when the network is gone. If you’ve ever launched a PWA and felt that nagging doubt when the Wi‑Fi drops, you know exactly what I mean.

The Insight

The reality is that a PWA isn’t magic just because you added a manifest.json. Offline‑first means you decide what to cache, when to update it, and how to fallback gracefully. The biggest win comes from separating app shell (the UI skeleton) from content (data fetched from APIs). Cache the shell aggressively, keep content fresh but stale‑while‑revalidate, and always have a fallback page for when even the shell isn’t there. Get that right and the app feels native even on a 2G connection.

How (with code)

Below is a trimmed‑down service worker that shows the pattern I ended up using. I’ll point out the two mistakes I made early on so you can skip the same headaches.

// sw.js
const CACHE_NAME = 'myapp-shell-v1';
const DATA_CACHE_NAME = 'myapp-data-v1';
const OFFLINE_PAGE = '/offline.html';

// Files that make up the app shell – HTML, CSS, JS, icons
const SHELL_ASSETS = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/manifest.json',
  '/icons/icon-192.png',
  '/icons/icon-512.png'
];

self.addEventListener('install', event => {
  // Mistake #1: caching everything in install, including big data payloads.
  // That bloats the cache and can cause install failures on slow connections.
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => cache.addAll(SHELL_ASSETS))
      .then(() => self.skipWaiting())
  );
});

self.addEventListener('activate', event => {
  // Clean up old caches – keep only the current shell and data versions.
  event.waitUntil(
    caches.keys().then(keys =>
      Promise.all(
        keys.filter(key => key !== CACHE_NAME && key !== DATA_CACHE_NAME)
            .map(key => caches.delete(key))
      )
    ).then(() => self.clients.claim())
  );
});

self.addEventListener('fetch', event => {
  const { request } = event;
  const url = new URL(request.url);

  // Route API calls to the data cache with stale‑while‑revalidate.
  if (url.origin === location.origin && url.pathname.startsWith('/api/')) {
    event.respondWith(
      caches.open(DATA_CACHE_NAME).then(cache =>
        fetch(request).then(networkResp => {
          // Put a clone in the cache for next time.
          cache.put(request, networkResp.clone());
          return networkResp;
        }).catch(() => cache.match(request)) // fallback to cached data
      )
    );
    return;
  }

  // For shell assets, try network first, fall back to cache.
  if (SHELL_ASSETS.includes(url.pathname)) {
    event.respondWith(
      fetch(request).catch(() => caches.match(request))
    );
    return;
  }

  // Anything else (e.g., images not in shell) – try network, then offline page.
  event.respondWith(
    fetch(request).catch(() => caches.match(OFFLINE_PAGE))
  );
});
Enter fullscreen mode Exit fullscreen mode

What I got wrong the first time

  1. Caching everything on install – I threw the whole /api response into the shell cache. The install step would stall on a flaky network, leaving users with a broken service worker. Splitting shell vs. data caches fixed that.

  2. Ignoring stale‑while‑revalidate for API data – I originally used cache-first for all fetches, which meant users saw yesterday’s data until they manually refreshed. Switching to a network‑first attempt with a cache fallback (and updating the cache in the background) gave a fresh experience most of the time while still working offline.

Notice the offline.html fallback – a tiny static page that says “You’re offline, but here’s what you can do.” It’s a lot friendlier than a blank screen or a browser’s dinosaur game.

Why This Matters

When the shell is cached, the app loads instantly, giving the perception of speed even before any data arrives. The data layer can be refreshed in the background, so users see up‑to‑date info without a full reload. And because we always have an offline page, there’s no scary “Failed to load resource” error – just a clear, branded message that tells the user what’s happening.

The trade‑off? You need to think about cache invalidation. If you change the shell (e.g., update a CSS file), you bump CACHE_NAME and the old cache gets purged on the next service worker activation. For data, you might version the API endpoint or rely on HTTP headers (Cache-Control: max-age=0) to keep things fresh. It’s a bit more work up front, but the payoff is a resilient app that feels native regardless of the network.

A challenge for you

Take a look at your current PWA (or the one you’re thinking of building). Identify one piece of UI that never changes – maybe the header, the navigation bar, or the login screen. Try caching just that piece in the shell and see how the load time changes on a throttled 3G connection. Did the perceived speed improve? What did you have to adjust in your fetch handler to keep data fresh? Drop your findings in the comments – I’d love to hear what worked and what tripped you up. Happy caching!

Top comments (0)