DEV Community

Marcc Atayde
Marcc Atayde

Posted on

Progressive Web Apps in 2025: A Production-Focused Guide to Service Workers, Caching, and the Install Experience

There's a version of this article that starts with "PWAs are the future." This isn't that article. Instead, let's talk about what PWAs actually buy you in production — where the real complexity lives, what the spec glosses over, and how to ship something that behaves reliably across Chrome, Safari, and Firefox without losing your mind.

If you've built SPAs with Laravel backends or worked in the TALL stack, you already have most of the mental models. Service workers are just cache interceptors with a lifecycle. The manifest is just metadata. The hard part is the orchestration — and the gaps in the spec that nobody warns you about.

What a PWA Actually Is (Past the Marketing)

Strip away the buzzwords and a PWA is three things working together:

  1. A web app manifest — JSON metadata that lets the browser offer an install prompt and controls how the app looks when installed.
  2. A service worker — a JS script running in a separate thread that intercepts network requests and manages caches.
  3. HTTPS — mandatory. No negotiation.

Nothing in that list requires React, a SPA, or even JavaScript-heavy architecture. A server-rendered Laravel app with Blade templates can be a perfectly valid PWA. The confusion happens when people conflate PWA capabilities with SPA patterns. They're orthogonal concerns.

The Manifest: More Than name and icons

Most tutorials show you a bare-bones manifest and move on. Here's a more complete version reflecting what you actually want in production:

{
  "name": "Field Service Tracker",
  "short_name": "FSTracker",
  "description": "Real-time job dispatch and field technician tracking.",
  "start_url": "/dashboard?source=pwa",
  "display": "standalone",
  "background_color": "#1e293b",
  "theme_color": "#3b82f6",
  "orientation": "portrait-primary",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
    { "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ],
  "screenshots": [
    { "src": "/screenshots/desktop.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide" },
    { "src": "/screenshots/mobile.png", "sizes": "390x844", "type": "image/png", "form_factor": "narrow" }
  ],
  "categories": ["business", "productivity"],
  "shortcuts": [
    {
      "name": "New Job",
      "url": "/jobs/create?source=shortcut",
      "icons": [{ "src": "/icons/shortcut-new-job.png", "sizes": "96x96" }]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

A few things worth noting:

  • start_url with a query param lets you segment PWA installs in your analytics without any extra instrumentation. Filter on source=pwa and you get a clean cohort.
  • Maskable icons are non-optional if you want the app to look correct on Android. Without a maskable variant, the OS applies its own shape and you get white-boxed icons.
  • Screenshots unlock the richer install dialog in Chrome on desktop and Android — the one that shows a preview instead of just a bare install button.
  • Shortcuts appear on long-press of the home screen icon. For task-oriented apps, this is a meaningful UX improvement with zero runtime cost.

Service Workers: The Lifecycle Is the Hard Part

The spec describes install, activate, and fetch as the three primary events. What it undersells is the waiting state — the source of most PWA update bugs in production.

When a new service worker is detected, it installs and waits. It won't activate until all tabs running the old SW are closed. This means users can run a stale version for days on a tab they never close. The typical fix is skipWaiting, but you need to coordinate with the client:

// sw.js
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('app-shell-v3').then((cache) => {
      return cache.addAll([
        '/',
        '/dashboard',
        '/offline.html',
        '/css/app.css',
        '/js/app.js',
      ]);
    })
  );
});

self.addEventListener('activate', (event) => {
  const allowedCaches = ['app-shell-v3', 'api-runtime-v1'];
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys
          .filter((key) => !allowedCaches.includes(key))
          .map((key) => caches.delete(key))
      )
    ).then(() => self.clients.claim())
  );
});

self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});
Enter fullscreen mode Exit fullscreen mode

Then on the client side, prompt the user when a new version is ready:

// In your main app JS
let newWorker;

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js').then((reg) => {
    reg.addEventListener('updatefound', () => {
      newWorker = reg.installing;
      newWorker.addEventListener('statechange', () => {
        if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
          // New version available — show a non-blocking toast
          showUpdateBanner(() => {
            newWorker.postMessage({ type: 'SKIP_WAITING' });
            window.location.reload();
          });
        }
      });
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

This pattern — detect, inform, let the user choose to reload — is far better UX than silently forcing a reload or leaving users stranded on stale builds.

Caching Strategies: Match the Strategy to the Resource

Don't use one strategy for everything. Here's how to think about it:

Resource Type Strategy Rationale
App shell (HTML, CSS, JS) Cache-first with versioned cache names Changes are deploy-time events, not runtime
API responses (read-heavy) Stale-while-revalidate Show cached data instantly, refresh in background
API responses (write operations) Network-first Stale data on mutations is dangerous
Images / fonts Cache-first with long TTL These rarely change
Offline fallback page Cache-only (pre-cached on install) Must be available when network fails

For the network-first with offline fallback pattern on API calls:

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

  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then((response) => {
          // Clone before consuming
          const clone = response.clone();
          caches.open('api-runtime-v1').then((cache) => {
            cache.put(event.request, clone);
          });
          return response;
        })
        .catch(() =>
          caches.match(event.request).then(
            (cached) => cached || caches.match('/offline.html')
          )
        )
    );
    return;
  }

  // Cache-first for everything else
  event.respondWith(
    caches.match(event.request).then(
      (cached) => cached || fetch(event.request)
    )
  );
});
Enter fullscreen mode Exit fullscreen mode

Background Sync for Offline Write Operations

The scenario: a field technician submits a job update with no signal. The naive approach loses the data. Background Sync queues the request and replays it when connectivity returns:

// On form submission
async function submitJobUpdate(data) {
  try {
    await fetch('/api/jobs', { method: 'POST', body: JSON.stringify(data) });
  } catch {
    // Store in IndexedDB
    await saveToQueue('job-updates', data);
    const reg = await navigator.serviceWorker.ready;
    await reg.sync.register('sync-job-updates');
  }
}

// In sw.js
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-job-updates') {
    event.waitUntil(flushJobUpdateQueue());
  }
});
Enter fullscreen mode Exit fullscreen mode

Browser support for Background Sync is still Chrome/Edge-only as of 2025. Safari supports the Periodic Background Sync spec differently. Plan your offline strategy accordingly and use it as progressive enhancement, not a hard dependency.

Safari: The Reality Check

Safari's PWA support has improved meaningfully since 2023, but gaps remain. Push notifications on iOS require the user to install the PWA first, and only work from the home screen — not the browser. The install prompt API (BeforeInstallPromptEvent) doesn't exist in Safari; you have to build your own "Add to Home Screen" instruction UI and detect the platform yourself:

const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent);
const isInStandaloneMode = window.matchMedia('(display-mode: standalone)').matches;

if (isIos && !isInStandaloneMode) {
  showIosInstallInstructions(); // Your custom UI
}
Enter fullscreen mode Exit fullscreen mode

This is the kind of implementation detail that separates a production PWA from a tutorial demo. Teams at hanzweb.ae working on cross-platform business apps spend a meaningful chunk of QA time on Safari edge cases alone — the spec says one thing, the browser does another, and the user experience depends on you catching the gap.

Testing Your Service Worker

Chrome DevTools → Application → Service Workers is your primary tool, but don't skip these:

  • Lighthouse PWA audit — catches manifest issues and SW registration failures
  • Simulate offline in DevTools → Network → Offline, then navigate around
  • Test the update flow explicitly — modify your SW, reload, confirm the waiting state appears, then trigger skip-waiting
  • Test on a real device — Android Chrome and iOS Safari behave differently than desktop DevTools emulation

Conclusion

PWAs earn their complexity when your users have intermittent connectivity, when you need app-store-like discoverability without the friction of a native submission process, or when a native app isn't justified by budget but installed-app UX is genuinely required. They're not a silver bullet — a poorly implemented service worker will make your app slower and harder to debug than a plain website.

The takeaway: invest time in the caching strategy and the update lifecycle. Get those two things right and everything else is configuration. Get them wrong and you're shipping a cache poisoning bug to every user who ever visited your site.

Build the manifest thoughtfully. Handle the waiting state. Match your cache strategy to the resource type. Test on real devices. That's the whole game.

Top comments (0)