DEV Community

Timevolt
Timevolt

Posted on

Building PWAs: The Offline-First Inception

The Quest Begins (The "Why")

Honestly, I was tired of watching my users stare at a blank screen the moment their Wi‑Fi sputtered. Imagine you’re deep in a RPG, you’ve just looted a rare sword, and the game decides to pause because the server went down. Frustrating, right? That’s exactly how my users felt when they tried to check their todo list on the train or in a coffee shop with spotty coverage. I kept hearing “It works fine when I’m at my desk” and thought, there’s gotta be a way to make the web feel as reliable as a native app.

That “aha!” moment hit when I saw a demo of a weather PWA that kept showing the last forecast even after I turned off airplane mode. It felt like discovering a hidden cheat code—suddenly the app was still useful, still alive, even when the network vanished. I realized the real dragon wasn’t slow APIs or ugly UI; it was the dependency on a constant connection. Slaying that beast meant going offline‑first.

The Revelation (The Insight)

The secret sauce? Service workers and a solid caching strategy. Think of a service worker as a tiny, trusty sidekick that intercepts every network request, decides whether to serve a cached copy or go to the network, and can even sync data when the connection returns. Pair that with a web app manifest (so the browser knows your PWA can be installed) and you’ve got a spell that turns a regular site into an offline‑first adventure.

The magic happens in three steps:

  1. Register the service worker – tell the browser where your sidekick lives.
  2. Cache the essential assets – HTML, CSS, JS, maybe a few images—during the install event.
  3. Intercept fetch events – serve from cache first, fall back to network, and update the cache in the background.

It’s like having a portable base camp: you stock it with supplies (cached files) before you head out, and whenever you need something, you check the camp first. If it’s missing, you venture out to the network, grab it, and bring it back to restock for next time.

Wielding the Power (Code & Examples)

Let’s see the before and after. First, a plain site that breaks when offline:

<!-- index.html (plain) -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Todo List</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <h1>My Todo List</h1>
  <ul id="todo-list"></ul>
  <input id="new-todo" placeholder="What needs doing?" />
  <button id="add">Add</button>
  <script src="app.js"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode
// app.js (no service worker)
document.getElementById('add').addEventListener('click', () => {
  const text = document.getElementById('new-todo').value.trim();
  if (!text) return;
  const li = document.createElement('li');
  li.textContent = text;
  document.getElementById('todo-list').appendChild(li);
  document.getElementById('new-todo').value = '';
});
Enter fullscreen mode Exit fullscreen mode

Open this, turn off Wi‑Fi, and you’ll see nothing but a blank page after a reload—your hard‑earned todos vanish because the browser can’t fetch index.html, style.css, or app.js.

Now, let’s add the PWA spell.

1. The Manifest

Create manifest.json:

{
  "name": "Offline Todo",
  "short_name": "Todo",
  "start_url": ".",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4caf50",
  "icons": [
    {
      "src": "icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Link it in index.html:

<link rel="manifest" href="manifest.json">
Enter fullscreen mode Exit fullscreen mode

2. Register the Service Worker

At the bottom of index.html (after the script tag):

<script>
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
      navigator.serviceWorker.register('sw.js')
        .then(reg => console.log('SW registered:', reg.scope))
        .catch(err => console.error('SW registration failed:', err));
    });
  }
</script>
Enter fullscreen mode Exit fullscreen mode

3. The Service Worker (sw.js)

Here’s where we cast the caching spell. I’ll show a common pitfall first, then the fixed version.

Trap #1 – Caching everything indiscriminately

If you cache every request, you might end up serving stale API data forever.

// ❌ Bad: caches all fetches, no network fallback
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(resp => resp || fetch(event.request))
  );
});
Enter fullscreen mode Exit fullscreen mode

The problem? If the network is down and the request isn’t cached, you get nothing—plus you never update the cache when you’re online.

Fixed version – cache‑first for assets, network‑first for API

const CACHE_NAME = 'todo-v1';
const ASSETS = [
  '/',
  '/index.html',
  '/style.css',
  '/app.js',
  '/icon-192.png',
  '/icon-512.png'
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(ASSETS))
  );
});

self.addEventListener('activate', event => {
  // Clean up old caches
  event.waitUntil(
    caches.keys()
      .then(keys => Promise.all(
        keys.filter(key => key !== CACHE_NAME)
          .map(key => caches.delete(key))
      ))
  );
});

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

  // 1. For same‑origin assets, use cache‑first
  if (url.origin === location.origin && ASSETS.includes(url.pathname)) {
    event.respondWith(
      caches.match(request).then(cached => cached || fetch(request))
    );
    return;
  }

  // 2. For API calls (e.g., /api/todos), try network first, then cache
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      fetch(request)
        .then(networkResp => {
          // Clone because response is a stream
          const clone = networkResp.clone();
          caches.open(CACHE_NAME).then(cache => cache.put(request, clone));
          return networkResp;
        })
        .catch(() => caches.match(request)) // fallback to cached response
    );
    return;
  }

  // 3. Everything else: network‑first, with cache as last resort
  event.respondWith(
    fetch(request).catch(() => caches.match(request))
  );
});
Enter fullscreen mode Exit fullscreen mode

Why this works:

  • During install, we precache the core UI files so the app can load instantly, even offline.
  • On fetch, we split strategies: static assets get cache‑first (fast, reliable), API requests try the network first (fresh data) but gracefully fall back to cached data when offline.
  • The activate step cleans out old caches, preventing storage bloat.

Common Mistakes to Avoid

  • Wrong scope: If you place sw.js in a subfolder, it only controls pages under that folder. Keep it at the root or adjust the scope in register.
  • Forgetting to serve over HTTPS: Service workers won’t register on localhost only if you’re using http:// (except for localhost dev). In production, HTTPS is mandatory.
  • Not updating the cache version: Change CACHE_NAME when you modify assets; otherwise the browser sticks with the old cache and you’ll see stale files.

Drop these files into your project, reload, open DevTools → Application → Service Workers, and you’ll see your sidekick active. Now toggle offline, refresh, and watch your todo list appear just as you left it—no server needed.

Why This New Power Matters

With this setup, your web app behaves like a native tool that you can install on a phone’s home screen, launch in a standalone window, and trust to work whether you’re on a 5G signal or deep underground in a subway. You’ve turned a fragile web page into a resilient experience—think of it as giving your app a force field against network gremlins.

The best part? You didn’t need to learn a new language or wrestle with native SDKs. You used the same HTML, CSS, and JavaScript you already love, just added a few lines of orchestration. It’s like discovering a hidden level in your favorite game where all the power‑ups are already in your inventory—you just had to know how to activate them.

Now you can ship features like background sync, push notifications, or even offline‑first analytics, confident that the core experience won’t collapse when the network takes a nap.

Your Turn

Grab a small project—maybe a weather widget, a note‑taking app, or that todo list we just built. Add a manifest, register a service worker, and experiment with caching strategies. Try the “network‑first, cache‑fallback” approach for API calls and see how snappy it feels when you flip the airplane mode switch.

What will you build that refuses to quit, even when the internet decides to take a coffee break? Share your wins (or your hilarious debugging tales) in the comments—I can’t wait to hear about your own offline‑first quest! 🚀

Top comments (0)