DEV Community

Timevolt
Timevolt

Posted on

Building PWAs: Offline-First Like a Jedi

The Quest Begins (The "Why")

Honestly, I was tired of seeing my users stare at a blank screen the moment their Wi‑Fi dropped. I’d built a nice little task‑manager app, shipped it, and then got a flood of tweets: “Love the UI, but it’s useless on the subway.” I felt like a Jedi trying to wield a lightsaber that only works when you’re standing next to the power source. The problem wasn’t the UI—it was the network dependency. I wanted my app to keep working whether the user was in a coffee shop with spotty Wi‑Fi or deep in a subway tunnel with zero bars. That’s when the idea of an offline‑first Progressive Web App hit me like a Force push: make the web app behave more like a native app that can survive without a constant connection.

The Revelation (The Insight)

The magic ingredient is the Service Worker—a script that runs in the background, separate from your page, and can intercept network requests. Think of it as your trusty droid (R2‑D2) that prefetches supplies, caches them, and serves them up when the main ship (your server) goes dark. Combine that with a solid Cache‑First strategy for static assets and a Network‑First fallback for fresh data, and you’ve got an app that feels instant, even when the network is laggy or absent.

What blew my mind was how little code it actually takes to get started. You don’t need a PhD in distributed systems; you just need to register the worker, define a few caches, and tell the browser what to do when a request comes in. The real trap? Forgetting to update the cache version when you change assets—then users get stuck with stale files, and you’ll hear the same complaints you were trying to fix.

Wielding the Power (Code & Examples)

The Struggle: A Plain Fetch

Here’s what a naïve version of my task list looked like before I added any offline smarts:

// public/js/app.js – before
async function loadTasks() {
  const resp = await fetch('/api/tasks');
  const data = await resp.json();
  renderTasks(data);
}

// Call on page load
loadTasks().catch(err => console.error('Network error', err));
Enter fullscreen mode Exit fullscreen mode

If the user loses connection, fetch throws, the UI shows nothing, and the user gets a sad face. Not very Jedi‑like.

The Victory: Adding a Service Worker

First, create a service worker file (sw.js). We’ll use a cache‑first approach for the app shell (HTML, CSS, JS) and a network‑first strategy for API data, falling back to cache if the network fails.

// sw.js
const CACHE_NAME = 'task-pwa-v1';
const ASSETS_TO_CACHE = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/icon-192.png',
  '/icon-512.png'
];

// Install – cache the static assets
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(ASSETS_TO_CACHE))
      .then(() => self.skipWaiting())
  );
});

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

// Fetch – decide how to respond
self.addEventListener('fetch', event => {
  const { request } = event;
  const url = new URL(request.url);

  // 1️⃣ API requests → network first, cache as fallback
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      fetch(request)
        .then(response => {
          // Optional: clone and put a fresh copy in cache
          const copy = response.clone();
          caches.open(CACHE_NAME).then(cache => cache.put(request, copy));
          return response;
        })
        .catch(() => caches.match(request)) // fallback to cached response
    );
    return;
  }

  // 2️⃣ Everything else → cache first, network as fallback
  event.respondWith(
    caches.match(request)
      .then(cached => cached || fetch(request))
  );
});
Enter fullscreen mode Exit fullscreen mode

Now we need to register the worker from our main script:

// public/js/app.js – after
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(reg => console.log('SW registered', reg))
    .catch(err => console.error('SW registration failed', err));
}

// Load tasks – now we rely on the SW to serve cached or fresh data
async function loadTasks() {
  const resp = await fetch('/api/tasks'); // SW will intercept
  const data = await resp.json();
  renderTasks(data);
}

// Call on page load
loadTasks().catch(err => console.error('Network error', err));
Enter fullscreen mode Exit fullscreen mode

Common Traps (The “Dark Side”)

  1. Skipping the skipWaiting() call – Without it, the new service worker won’t take control until all tabs are closed, leaving users with an old version.
  2. Caching API responses indiscriminately – If you cache POST/PUT requests, you might serve stale data. Stick to caching GET requests only, or use a stale‑while‑revalidate pattern for APIs.
  3. Forgetting to update CACHE_NAME – When you change any file in ASSETS_TO_CACHE, bump the version string. Otherwise the install step thinks nothing changed and never updates the cache.

Why This New Power Matters

With the service worker in place, my task manager now feels like a native app:

  • Instant load on repeat visits because the shell is served from cache.
  • Resilience – users can add tasks offline; the UI optimistically updates, and when the network returns, the background sync (or a simple retry) pushes the changes to the server.
  • Performance gain – even on a flaky 3G connection, the app feels snappy because the heavy lifting (HTML/CSS/JS) never touches the network.

Pretty cool, right? It’s like giving your web app a lightsaber that can cut through both network noise and silence.

Your Turn: Embark on Your Own Quest

Grab a small project—maybe a weather widget, a note‑taking page, or that todo list you’ve been meaning to polish. Add a service worker with the cache‑first/static + network‑first/API pattern we just walked through. Play with Chrome DevTools → Application → Service Workers to see the cache fill up, and try turning off the network (the “Offline” checkbox) to watch your app keep working.

When you see that first offline‑friendly toast pop up saying “You’re offline, but your tasks are still here,” you’ll feel like you’ve just leveled up your developer Jedi rank. May the cache be with you! 🚀

Top comments (0)