Skip to content

Progressive Web Apps: Offline-Capable Web Experiences

A practical guide to building progressive web apps with service workers, caching strategies, and installability that work reliably offline.

4 min read
Mobile device showing a progressive web app with offline indicator

Progressive Web Apps are not a framework or a library. They are a set of capabilities — offline support, installability, background sync — that make web applications behave like native apps. The technology has been stable for years, but adoption remains low because developers treat PWA as an all-or-nothing commitment instead of an incremental upgrade.

You do not need to go fully offline-first. Adding a service worker that caches your shell and handles network failures gracefully already puts you ahead of 90% of web applications.

The Service Worker Lifecycle

A service worker is a JavaScript file that runs in a separate thread, intercepting network requests between your app and the server. Understanding its lifecycle prevents the most common PWA bugs.

jsjavascript
// sw.js
const CACHE_NAME = 'app-cache-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles/main.css',
  '/scripts/app.js',
  '/offline.html',
];
 
// Install: pre-cache critical assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(STATIC_ASSETS);
    })
  );
  self.skipWaiting();
});
 
// Activate: clean up old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) => {
      return Promise.all(
        keys
          .filter((key) => key !== CACHE_NAME)
          .map((key) => caches.delete(key))
      );
    })
  );
  self.clients.claim();
});

The install event fires once when the service worker is first registered. The activate event fires after the old service worker is removed. Between those events, the new worker waits — which is why users sometimes see stale content until they close all tabs.

skipWaiting() and clients.claim() force immediate activation. Use them for non-breaking cache updates. Avoid them when the cache structure changes significantly — you might serve a new HTML shell with references to old cached assets.

Caching Strategies

Different resources need different caching strategies. Static assets rarely change. API responses change constantly. Applying the wrong strategy to either creates problems.

Cache First (Static Assets)

jsjavascript
// Best for: CSS, JS bundles, images, fonts
self.addEventListener('fetch', (event) => {
  if (event.request.destination === 'style' ||
      event.request.destination === 'script' ||
      event.request.destination === 'image') {
    event.respondWith(
      caches.match(event.request).then((cached) => {
        return cached || fetch(event.request).then((response) => {
          const clone = response.clone();
          caches.open(CACHE_NAME).then((cache) => {
            cache.put(event.request, clone);
          });
          return response;
        });
      })
    );
  }
});

Network First (API Data)

jsjavascript
// Best for: API responses, user-specific data
async function networkFirst(request) {
  const cache = await caches.open('api-cache');
 
  try {
    const networkResponse = await fetch(request);
    // Only cache successful responses
    if (networkResponse.ok) {
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  } catch (error) {
    const cachedResponse = await cache.match(request);
    if (cachedResponse) return cachedResponse;
    // Return a meaningful offline response
    return new Response(
      JSON.stringify({ error: 'offline', cached: false }),
      { headers: { 'Content-Type': 'application/json' } }
    );
  }
}

Stale While Revalidate (Semi-Dynamic Content)

jsjavascript
// Best for: blog posts, product listings, non-critical API data
async function staleWhileRevalidate(request) {
  const cache = await caches.open('content-cache');
  const cachedResponse = await cache.match(request);
 
  const fetchPromise = fetch(request).then((networkResponse) => {
    if (networkResponse.ok) {
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  });
 
  // Return cached immediately, update in background
  return cachedResponse || fetchPromise;
}

This is often the best default strategy — users get instant responses from cache while the service worker silently updates the cached version for next time.

The Web App Manifest

The manifest makes your app installable. Without it, browsers will not show the "Add to Home Screen" prompt.

jsonjson
{
  "name": "My Application",
  "short_name": "MyApp",
  "description": "A fast, offline-capable web application",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#1a1a2e",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-maskable.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ]
}

The display: "standalone" mode removes the browser chrome, making the app look native. Include both regular and maskable icons — Android adaptive icons need the maskable variant for proper rendering.

Handling Offline State

The worst offline experience is no experience — a blank page or a browser error. The second worst is pretending to work and silently dropping user actions.

tstypescript
// ❌ Ignores network state — user thinks action succeeded
async function submitForm(data: FormData) {
  await fetch('/api/submit', { method: 'POST', body: data });
  showSuccess('Submitted!');
}
 
// ✅ Queues offline actions and provides honest feedback
async function submitForm(data: FormData) {
  if (!navigator.onLine) {
    await saveToOutbox(data);
    showInfo('Saved offline. Will submit when connection returns.');
    return;
  }
 
  try {
    await fetch('/api/submit', { method: 'POST', body: data });
    showSuccess('Submitted!');
  } catch {
    await saveToOutbox(data);
    showInfo('Network error. Queued for retry.');
  }
}

IndexedDB provides reliable offline storage for queued actions:

tstypescript
async function saveToOutbox(data: FormData) {
  const db = await openDB('app-db', 1, {
    upgrade(db) {
      db.createObjectStore('outbox', {
        keyPath: 'id',
        autoIncrement: true,
      });
    },
  });
 
  const serialized = Object.fromEntries(data.entries());
  await db.add('outbox', {
    url: '/api/submit',
    body: serialized,
    timestamp: Date.now(),
  });
}

Background Sync

The Background Sync API lets your service worker retry failed requests when connectivity returns — even if the user has closed the tab.

jsjavascript
// In your app code — register a sync
async function requestBackgroundSync() {
  const registration = await navigator.serviceWorker.ready;
  await registration.sync.register('outbox-sync');
}
 
// In sw.js — handle the sync event
self.addEventListener('sync', (event) => {
  if (event.tag === 'outbox-sync') {
    event.waitUntil(processOutbox());
  }
});
 
async function processOutbox() {
  const db = await openDB('app-db', 1);
  const items = await db.getAll('outbox');
 
  for (const item of items) {
    try {
      await fetch(item.url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(item.body),
      });
      await db.delete('outbox', item.id);
    } catch {
      // Will retry on next sync event
      break;
    }
  }
}

Background Sync has good browser support in Chromium browsers. For Safari and Firefox, fall back to checking connectivity on page load and processing the outbox then.

Key Takeaways

  1. Start with a basic service worker — caching the app shell and handling offline gracefully covers most use cases
  2. Match caching strategy to content type — cache-first for static assets, network-first for API data, stale-while-revalidate for the middle ground
  3. Version your caches — old cache cleanup in the activate event prevents serving stale assets
  4. Be honest about offline state — queue actions and tell users what happened instead of silently failing
  5. Background Sync completes offline workflows — let the service worker retry when connectivity returns
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX