DEV Community

Cover image for # 8 Proven Techniques for Building Offline-First PWAs with Service Workers
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

# 8 Proven Techniques for Building Offline-First PWAs with Service Workers

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

The first time I truly understood what an offline-first web app could do, I was sitting on a train going through a tunnel. The app I was using just kept working. It didn't show me a sad dinosaur or a loading spinner that never ends. It showed me the content I wanted, and it felt like magic. That was a Progressive Web App, and behind that magic were service workers and a few clever JavaScript techniques. Over the years, I have built several PWAs, and each time I learned something new about handling network failures gracefully. Let me walk you through eight techniques that turned my web apps from fragile to resilient. I will explain them as simply as I can, and I will show you the exact code I use.


Technique 1: Registering a Service Worker and Managing Its Lifecycle

A service worker is just a JavaScript file that runs in the background, separate from your web page. But it has a strict lifecycle: install, activate, and then it can control pages. The first thing you have to do is register it. I learned the hard way that registration alone is not enough. You have to handle updates, notify users, and handle cases where the browser does not support service workers at all.

class ServiceWorkerManager {
  constructor(swPath = '/sw.js') {
    this.swPath = swPath;
    this.registration = null;
    this.controller = null;
    this.updateHandlers = new Set();
    this.ready = false;
    this.initialize();
  }

  async initialize() {
    if (!('serviceWorker' in navigator)) {
      console.warn('Service workers not supported');
      return;
    }

    try {
      this.registration = await navigator.serviceWorker.register(this.swPath, {
        scope: '/',
        updateViaCache: 'none'
      });

      this.registration.addEventListener('updatefound', () => {
        const newWorker = this.registration.installing;
        this.trackInstallingWorker(newWorker);
      });

      this.controller = navigator.serviceWorker.controller;
      this.ready = true;
      this.setupMessageListener();
      this.checkForUpdates();

      console.log('Service worker registered:', this.registration.scope);
    } catch (error) {
      console.error('Service worker registration failed:', error);
    }
  }

  trackInstallingWorker(worker) {
    worker.addEventListener('statechange', () => {
      if (worker.state === 'installed') {
        if (navigator.serviceWorker.controller) {
          // New version available
          this.notifyUpdateAvailable(worker);
        } else {
          console.log('Service worker installed for the first time');
        }
      }
    });
  }

  notifyUpdateAvailable(worker) {
    this.updateHandlers.forEach(handler => handler(worker));
    const event = new CustomEvent('swupdate', {
      detail: { worker, registration: this.registration }
    });
    window.dispatchEvent(event);
  }

  onUpdate(handler) {
    this.updateHandlers.add(handler);
    return () => this.updateHandlers.delete(handler);
  }
}
Enter fullscreen mode Exit fullscreen mode

I wrap this in a class called ServiceWorkerManager. I call initialize() when the page loads. If the browser does not support service workers, I quietly log a warning and fall back to normal network requests. When a new service worker is detected, I fire a custom event so my application can prompt the user to update. I also run a periodic check for updates every hour. This seems small, but it saves you from frustrated users who see old versions of your app for days.


Technique 2: Choosing the Right Caching Strategy for Each Resource

Not all resources are created equal. Your app shell (HTML, CSS, JavaScript) can be cached aggressively because it rarely changes. User-specific data from an API should probably come from the network first, and only fall back to a cached copy when you are offline. Images and fonts can be served stale-while-revalidate: give the cached version immediately, then update it in the background. I divide my requests into four categories.

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

  if (request.method !== 'GET') return;

  if (request.mode === 'navigate') {
    event.respondWith(networkFirstWithFallback(request));
    return;
  }

  if (isStaticAsset(url)) {
    event.respondWith(cacheFirstWithRefresh(request, STATIC_CACHE));
    return;
  }

  if (isImage(url)) {
    event.respondWith(staleWhileRevalidate(request, ASSET_CACHE));
    return;
  }

  if (isAPIRequest(url)) {
    event.respondWith(networkFirstForAPI(request));
    return;
  }

  event.respondWith(networkFirstWithFallback(request));
});
Enter fullscreen mode Exit fullscreen mode

The cacheFirstWithRefresh function tries the cache first. If it finds a match, it returns it immediately and starts a network fetch in the background to replace the cache entry. This keeps the response fast while ensuring freshness for the next load. The networkFirstWithFallback function tries the network, and if it fails, it falls back to the cache. If neither is available, it serves an offline page. I like the staleWhileRevalidate pattern for images because it makes the page load instantly, even on slow connections.


Technique 3: Queuing User Actions with Background Sync

One of the biggest mistakes I made early on was letting users lose their work when they went offline. Now I use a background sync manager that stores every user action in IndexedDB when offline and replays it when the connection returns.

class BackgroundSyncManager {
  constructor() {
    this.syncQueue = [];
    this.syncHandlers = new Map();
    this.setupEventListeners();
  }

  setupEventListeners() {
    window.addEventListener('online', () => {
      console.log('Online - processing sync queue');
      this.processSyncQueue();
    });
  }

  async queueAction(action) {
    const syncItem = {
      id: this.generateId(),
      type: action.type,
      data: action.data,
      timestamp: Date.now(),
      retryCount: 0
    };
    this.syncQueue.push(syncItem);
    await this.persistQueue();
    // Register for background sync if available
    if ('sync' in navigator.serviceWorker) {
      await navigator.serviceWorker.ready.then(reg => reg.sync.register('sync-data'));
    } else if (navigator.onLine) {
      this.processSyncQueue();
    }
    return syncItem.id;
  }

  registerHandler(actionType, handler) {
    this.syncHandlers.set(actionType, handler);
  }

  async processSyncQueue() {
    // ... sort, iterate, call handler, remove on success, retry on failure
  }
}
Enter fullscreen mode Exit fullscreen mode

I register handlers for each action type, like createPost or updateProfile. When the user submits a form while offline, I queue the action. The queue is persisted in IndexedDB so it survives page refreshes. As soon as the browser detects a network connection, it processes the queue in order. If a request fails, I retry it up to three times with increasing delays. This technique turns a disconnected experience into a seamless one. My users no longer lose comments, votes, or edits.


Technique 4: Using Cache Versioning and Cleaning Up Old Caches

When you update your service worker, the old cache might still be around. If you serve outdated assets, your app can break in subtle ways. I version my caches with a variable like CACHE_VERSION and, in the activate event, remove any cache that does not match the current version.

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => {
            return name !== STATIC_CACHE && name !== DYNAMIC_CACHE;
          })
          .map((name) => caches.delete(name))
      );
    }).then(() => {
      return self.clients.claim();
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

This ensures that after a new service worker takes over, it cleans up old storage. I also add a skipWaiting() in the install event so the new service worker activates immediately. Combined with the update notification in Technique 1, users get the latest version without any manual cache clearing.


Technique 5: Creating a Dynamic Cache Management API

Sometimes you need to let the user or your application inspect and manage caches. For example, you might want to show how much space each cache takes, or allow the user to clear the dynamic cache. I expose a message API from the service worker that handles commands like CLEAR_CACHES, GET_CACHE_SIZE, and DELETE_CACHE.

self.addEventListener('message', (event) => {
  const { type, data } = event.data;
  switch (type) {
    case 'CLEAR_CACHES':
      clearAllCaches().then(() => event.source.postMessage({ type: 'cachesCleared' }));
      break;
    case 'GET_CACHE_SIZE':
      getCacheSizes().then(sizes => event.source.postMessage({ type: 'cacheSizes', data: sizes }));
      break;
    // ...
  }
});
Enter fullscreen mode Exit fullscreen mode

On the client side, I use navigator.serviceWorker.controller.postMessage(...). This gives me a direct line to ask the service worker for information or to perform maintenance. I once built a settings page where users could see exactly how many pages were cached and clear the data if they wanted. It made them trust the app more.


Technique 6: Designing Fallback Pages for Every Scenario

Even with the best caching, sometimes a request can fail and there is no cached copy. For navigation requests, I serve a custom offline page. For API requests, I return a JSON response that tells the UI to show a friendly message. I write these fallbacks in the networkFirstWithFallback function.

async function networkFirstWithFallback(request) {
  try {
    const networkResponse = await fetch(request);
    if (networkResponse.ok) {
      const cache = await caches.open(DYNAMIC_CACHE);
      cache.put(request, networkResponse.clone());
      return networkResponse;
    }
    throw new Error('Network response not OK');
  } catch (error) {
    const cachedResponse = await caches.match(request);
    if (cachedResponse) return cachedResponse;
    // Return offline page
    const offlinePage = await caches.match('/offline.html');
    if (offlinePage) return offlinePage;
    // Last resort: minimal HTML fallback
    return new Response(
      '<html><body><h1>Offline</h1><p>Content not available offline.</p></body></html>',
      { headers: { 'Content-Type': 'text/html' } }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

I always make sure the offline page is part of the static cache during install. The offline page should have a button to retry or at least a comforting message. I also cache a generic JSON response for APIs that says "You are offline. This data might be outdated." That way my JavaScript can handle the state gracefully instead of crashing.


Technique 7: Communication Between Service Worker and Client

The service worker and the page can talk to each other using postMessage. This is how I notify the page about sync completions, cache updates, and errors. I set up a listener in the ServiceWorkerManager that dispatches custom events for different message types.

// In the service worker:
async function sendMessageToClients(message) {
  const clients = await self.clients.matchAll();
  clients.forEach(client => client.postMessage(message));
}

// In the page:
navigator.serviceWorker.addEventListener('message', (event) => {
  const { type, data } = event.data;
  switch (type) {
    case 'cacheUpdated':
      this.handleCacheUpdate(data);
      break;
    case 'syncComplete':
      this.handleSyncComplete(data);
      break;
    case 'error':
      console.error('Service worker error:', data);
      break;
  }
});
Enter fullscreen mode Exit fullscreen mode

I use this to show a toast when a background sync finishes, or to update a status indicator. It makes the offline-first experience feel interactive and responsive. Without this communication, the user would never know that their queued action was finally sent.


Technique 8: Persisting Queued Data with IndexedDB

For offline queuing to survive a browser restart, you need a client-side database. IndexedDB is perfect for this. In my BackgroundSyncManager, I open a database with an object store called syncQueue. I use simple promises to handle read and write operations.

openDatabase() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('BackgroundSyncDB', 1);
    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      if (!db.objectStoreNames.contains('syncQueue')) {
        db.createObjectStore('syncQueue', { keyPath: 'id' });
      }
    };
    request.onsuccess = (event) => resolve(event.target.result);
    request.onerror = (event) => reject(event.target.error);
  });
}

async persistQueue() {
  const db = await this.openDatabase();
  const transaction = db.transaction('syncQueue', 'readwrite');
  const store = transaction.objectStore('syncQueue');
  await store.clear();
  for (const item of this.syncQueue) {
    await store.add(item);
  }
  await transaction.done;
}
Enter fullscreen mode Exit fullscreen mode

I also load the queue when the manager initializes, so queued actions from a previous session are restored. This technique turns your app into a reliable offline tool. I once had a field worker use my PWA in a remote area with spotty connectivity. The queued forms synced automatically whenever they reached a town. They never lost a single record.


These eight techniques transformed how I build web applications. They are not just about caching static files. They are about respecting the user's time and data, and providing a dependable experience no matter the network. I encourage you to start small: register a service worker, choose one caching strategy, and add an offline page. Then gradually incorporate background sync and dynamic management. Every step makes your web app more like a native app. And the best part? You are still building for the web, with all its reach and accessibility.

The train tunnel example I started with taught me that offline-first is not a luxury. It is a necessity for anyone who travels, works in low-coverage areas, or simply expects the web to work the same way their phone apps do. The tools are here. The code is simple. All you need is the will to change how you think about the network. Start treating it as an optional enhancement, not a requirement. Your users will thank you.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)