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:
- A web app manifest — JSON metadata that lets the browser offer an install prompt and controls how the app looks when installed.
- A service worker — a JS script running in a separate thread that intercepts network requests and manages caches.
- 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" }]
}
]
}
A few things worth noting:
-
start_urlwith a query param lets you segment PWA installs in your analytics without any extra instrumentation. Filter onsource=pwaand 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();
}
});
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();
});
}
});
});
});
}
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)
)
);
});
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());
}
});
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
}
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)