Offline PWAs with Service Workers

WebPWAService WorkersFrontend
Share on LinkedIn

Airplane mode used to mean our field technician app was a brick. Service workers turned it into something that loads the job list from cache, queues form submissions, and syncs when connectivity returns. The hard part wasn't registering navigator.serviceWorker — it was picking caching strategies that don't serve week-old prices or lose draft inspections when the worker updates mid-shift.

Service worker lifecycle

Register ──► Install (precache) ──► Activate (cleanup old caches)
                    │                        │
                    └──── Waiting ───────────┘ (until tabs close or skipWaiting)
// main.js
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js').then((reg) => {
    reg.addEventListener('updatefound', () => {
      const newWorker = reg.installing;
      newWorker.addEventListener('statechange', () => {
        if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
          showUpdateBanner(); // user chooses when to refresh
        }
      });
    });
  });
}

Never skipWaiting() silently on apps with forms in progress.

Caching strategies

Strategy Flow Best for
Cache-first Cache → network if miss Hashed JS/CSS, fonts
Network-first Network → cache fallback API, auth-sensitive
Stale-while-revalidate Cache immediately, update cache in background Avatars, catalog images
Network-only Always network Analytics, mutations
// sw.js with Workbox
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { NetworkFirst, StaleWhileRevalidate, CacheFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';

precacheAndRoute(self.__WB_MANIFEST);

registerRoute(
  ({ request }) => request.destination === 'document',
  new NetworkFirst({ cacheName: 'pages', networkTimeoutSeconds: 3 })
);

registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api',
    plugins: [new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 300 })],
  })
);

registerRoute(
  ({ request }) => ['style', 'script', 'font'].includes(request.destination),
  new CacheFirst({ cacheName: 'static-v2' })
);

Offline UI and data

Show connectivity state:

window.addEventListener('online', () => syncPendingSubmissions());
window.addEventListener('offline', () => showOfflineBanner());

Queue mutations in IndexedDB:

async function submitInspection(data) {
  if (!navigator.onLine) {
    await idb.put('pending', { ...data, id: crypto.randomUUID() });
    return { queued: true };
  }
  return fetch('/api/inspections', { method: 'POST', body: JSON.stringify(data) });
}

async function syncPendingSubmissions() {
  for (const item of await idb.getAll('pending')) {
    await fetch('/api/inspections', { method: 'POST', body: JSON.stringify(item) });
    await idb.delete('pending', item.id);
  }
}

Background Sync API (registration.sync.register('sync-inspections')) retries when online — Chrome-first; have fallback online listener.

App shell pattern

Precache minimal HTML/JS skeleton; fetch content into shell:

<!-- index.html shell -->
<div id="app">Loading...</div>
<script src="/app.js"></script>

Shell loads offline instantly; data populates when available or from cache.

Manifest and installability

{
  "name": "Field Inspect",
  "short_name": "Inspect",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#1a56db",
  "icons": [{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }]
}

Install prompt requires manifest + service worker + HTTPS.

Pitfalls

Storage quota management

Monitor navigator.storage.estimate() — prompt users to clear cache when quota exceeded. Implement cache eviction LRU for API responses. iOS may evict storage under pressure; critical data belongs in sync queue with user-visible "pending upload" state.

Operational notes

Test service worker update flow with Playwright — register SW, deploy v2, assert update banner appears, user refresh loads new cache. SW bugs are invisible in unit tests without browser integration coverage.

Document offline limitations in UI — users who expect full feature parity offline without indication blame the app, not the connectivity gap.

Ship offline-capable flows with explicit sync status indicators — ambiguous spinners when offline erode trust more than honest "waiting for connection" messaging with retry button.

Version service worker file name in cache busting strategy — browsers cache sw.js aggressively; configure build pipeline to inject hash into registration URL when needed.

Add E2E tests that toggle browser offline mode in Playwright — service worker regressions rarely surface in unit tests without full browser lifecycle coverage.

Version service worker cache names on deploy — stale cache names serve old assets after deployment until user hard-refreshes.

Cache versioning strategy

const CACHE = 'app-v3';
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(keys =>
      Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
    )
  );
});

Bump cache name on every deploy. Stale-while-revalidate for API JSON; cache-first for hashed static assets.

Common production mistakes

Teams get offline service worker wrong in predictable ways:

Production implementations of offline service worker fail when staging mirrors production topology poorly, rollback is untested, and on-call runbooks describe the happy path only.

Debugging and triage workflow

When offline service worker misbehaves in production, work top-down instead of guessing:

  1. Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
  2. Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
  3. Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
  4. Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
  5. Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
  6. Add a guard — alert, integration test, or circuit breaker so the same class of failure is caught earlier next time.

Document the timeline during triage. Future you (and on-call) will need timestamps, not just conclusions.

Resources

Frequently asked questions

What is the difference between a service worker and a web worker?

A service worker is a proxy between your app and the network — it intercepts fetch requests and can cache responses for offline use. Web workers run scripts in background threads for computation. Service workers enable offline PWAs; web workers enable parallel processing.

Which caching strategy should a PWA use for API data?

Network-first with cache fallback for frequently changing API data — try network, fall back to cache when offline. Cache-first suits static assets with hashed filenames. Stale-while-revalidate works for semi-static content like product catalogs.

How do service worker updates reach users without breaking sessions?

New service worker installs in waiting state until all tabs close or you call skipWaiting with user consent. Prompt 'Update available — refresh' rather than force-reloading mid-form. Version cache names (v1, v2) and delete old caches on activate.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →