devShakib

I Stopped Building PWAs for Everyone and Started Building Them for Three Things

Build Progressive Web Apps only for three things: offline reliability, background sync, and hardware access. A practical service worker, caching, and iOS guide.

I have shipped three Progressive Web Apps that nobody needed and one that saved a client's warehouse operation. The difference between them was not the code quality. It was whether the PWA solved a problem the user could feel, or whether I was just cosplaying as a native app on the web. Somewhere around 2019 the industry decided a PWA was a checkbox: add a manifest, register a service worker, pass the Lighthouse audit, drop an "Install" button in the corner, and congratulate yourself. I did exactly that on three projects. Two of them, nobody ever installed. The service workers just sat there, quietly serving stale data and generating support tickets I then had to answer at 11pm.

Here is the hard-won version of what I believe now: installability is not a feature. It is a side effect. A Progressive Web App earns its maintenance cost only when it solves one of three real problems — offline reliability, background sync, or hardware access. Everything else is a native-app cargo cult, a group of us performing the rituals of "app-ness" on the web and hoping the plane lands.

The PWA hype cycle and the graveyard it left behind

The PWA pitch was seductive because it was true in the demo. One codebase, no app store tax, no review queue, instant updates, an icon on the home screen. For a Dubai startup counting every dirham, that story sells itself. I sold it to myself.

What the demo hides is the tail. A native app on the store is a fixed, versioned artifact. A PWA is a living thing made of a web app manifest, a service worker, an install prompt, a cache, and a browser that changes the rules every six weeks. Each of those is a small maintenance liability. Bundle them for an app that had no offline requirement and no hardware need, and you have built a slower website that also breaks in exciting new ways after a Chrome update.

The graveyard is full of PWAs whose only justification was "so users can install it." Those users didn't install it. On a marketing site I inherited, the install-prompt acceptance rate was under 2%, and of those, most never opened it a second time. We were carrying a service worker — and its whole cache-invalidation surface — to serve a bookmark that a browser bookmark would have handled for free.

The thing to internalize is that a PWA is not free just because it is "just the web." Every capability you add — caching, offline storage, push, install prompts — is a new surface that can fail independently, on a matrix of browsers you do not control. You are not shipping a website with a badge. You are taking on a distributed-systems problem where one of the nodes is the user's phone in a pocket with a dead battery.

Installability is not a feature

Let me be precise about the thing people keep treating as the goal.

Users do not want to install your web app. They want the thing your web app does. Installation is a cost they pay — a permission dialog, an icon-grid decision, a "do I trust this" moment — in exchange for a benefit. If there is no benefit on the other side of that cost, the smart user declines, and the ones who accept are just noise in your metrics.

What users actually notice, in order:

Notice what is not on that list: the manifest, the theme color, the splash screen, the little "add to home screen" banner. Those are plumbing. Necessary sometimes, but nobody chose your product because your address bar was hidden. If your PWA's headline benefit is "it hides the browser chrome," you built a native-app costume, not a product.

This is also why I no longer chase a perfect Lighthouse PWA score as an end in itself. Lighthouse tells you the plumbing is correct — installable manifest, HTTPS, a registered service worker. It cannot tell you whether the offline experience is useful. A green audit on a site that has no reason to work offline is a green audit on a costume.

The three problems a PWA actually solves best

Here is my whole filter now. Before I write a single line of service-worker code, the project has to clear one of these bars.

1. Offline reliability

The network is not a given. It is a flaky, expensive, sometimes-absent resource. If your users work in warehouses, on planes, in elevators, in rural clinics, in a parking garage in Dubai where the signal dies at level -3 — offline is a feature they will feel every single day.

This is the one case where a service worker is not a liability but the entire point. You are trading complexity for the ability to function when the network doesn't. An offline-first web app treats the local cache as the source of truth for rendering and the network as an eventually-consistent upstream, not the other way around.

The one PWA I'm proud of was exactly this. A client ran a warehouse where the Wi-Fi died reliably in the back third of the building — steel racking, concrete, a signal that dropped to nothing around aisle 40. Their pickers were using a web tool that froze every time they walked out of coverage, and they'd lose the scan they were mid-way through. We cached the shell, moved scans into a local queue, and the tool simply stopped caring about the network. Freeze-related support tickets from that site went from a steady weekly trickle to zero. That is what a PWA is for. Nobody there ever mentioned the install icon.

2. Background sync

Related but distinct. Offline is about reading when there's no network. Background sync is about writing — capturing an action now and guaranteeing it reaches the server later, even after the tab is closed and the phone is in a pocket. Field data entry, messaging, order capture, timesheet punches. If a lost mutation costs the user real money or real trust, sync is worth building.

The mental model that keeps me out of trouble: the user's action and the network request are two separate events that may be minutes or hours apart. Your UI acknowledges the action immediately against durable local storage; a separate, retry-safe process reconciles it with the server whenever connectivity allows.

3. Hardware and capability access

Camera, geolocation, Bluetooth (on Chromium), file system access, push notifications, share targets. If your product's core loop involves the device, and the web platform can actually reach that hardware, a PWA can be dramatically cheaper than a native build. The word "actually" is doing heavy lifting there — see the capability gap section.

If a project clears none of these three, I don't build a PWA. I build a good, fast, responsive website and stop. A website that loads in 400ms beats a PWA that loads in 500ms and occasionally serves last week's prices.

Service workers as a liability: caching that doesn't rot

The service worker is the most powerful and most dangerous thing in the stack. It sits between your app and the network with the authority to lie to your users about what the current data is. Get the caching strategy wrong and you ship a bug that persists across deploys, because the fix is also behind the broken cache.

It helps to name the strategies you are choosing between, because the whole game is matching the right one to the right resource:

My rules, learned the hard way:

Never cache HTML and API responses with the same strategy. The static shell can be cache-first. Live data must not be.

Use network-first (or stale-while-revalidate) for anything that changes. Cache-first on dynamic data is how you show a user a price from last Tuesday.

Here is the routing shape I actually ship, using Workbox because hand-rolling this is a mistake I've made and won't repeat:

import { registerRoute } from 'workbox-routing';import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';import { ExpirationPlugin } from 'workbox-expiration';// App shell: cache-first, but versioned so a deploy busts it.registerRoute(  ({ request }) => request.destination === 'script' || request.destination === 'style',  new CacheFirst({ cacheName: 'shell-v3' }));// API data: network-first with a short timeout, cache only as a fallback.registerRoute(  ({ url }) => url.pathname.startsWith('/api/'),  new NetworkFirst({    cacheName: 'api',    networkTimeoutSeconds: 4,    plugins: [new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 60 * 30 })],  }));// Images: revalidate in the background so they never block a render.registerRoute(  ({ request }) => request.destination === 'image',  new StaleWhileRevalidate({ cacheName: 'images' }));

Two non-negotiables around that code. First, version your cache names and clean up old ones on activate, or you will leak megabytes of dead cache onto users' devices. Second, ship a real update path — skipWaiting plus a "new version available, refresh?" prompt — because a service worker that only updates on the second reload means your users are always one version behind, including behind your bug fixes.

self.addEventListener('activate', (event) => {  const keep = ['shell-v3', 'api', 'images'];  event.waitUntil(    caches.keys().then((names) =>      Promise.all(names.filter((n) => !keep.includes(n)).map((n) => caches.delete(n)))    )  );});

A caching strategy that doesn't rot is one that has an expiry, a version, and a way to be forcibly replaced. If you can't answer "how does a user get out of a bad cache," you don't have a strategy, you have a time bomb.

One more edge case that has bitten me: the service worker's own script is itself subject to HTTP caching. If your server sends a long Cache-Control max-age on the sw.js file, browsers may not check for a new service worker for hours. Serve the service worker script with Cache-Control: no-cache (or a very short max-age) so update checks actually happen. The cache you most need to invalidate is the one guarding the code that invalidates caches.

The offline queue that survives a dead tunnel

Background sync is where PWAs stop being fancy websites and start being genuinely hard to replicate. The pattern that has saved me: never let a mutation depend on the network being up at that instant. Write the user's intent to durable local storage first, return success to the UI, then drain the queue when connectivity returns.

The Background Sync API handles the "when connectivity returns, even if the tab is closed" part — on Chromium. IndexedDB (I use the idb wrapper) holds the queue, because localStorage is synchronous, small, and string-only, which makes it the wrong tool for a durable outbox.

// On a user action: enqueue first, sync later. The UI never waits on the wire.async function submitReport(report) {  const db = await openQueueDb();  await db.add('outbox', { ...report, queuedAt: Date.now() });  if ('serviceWorker' in navigator && 'SyncManager' in window) {    const reg = await navigator.serviceWorker.ready;    await reg.sync.register('flush-outbox'); // fires when back online  } else {    flushOutbox(); // fallback for browsers without Background Sync (hi, iOS)  }}
// In the service worker: drain the outbox, and don't lose items on partial failure.self.addEventListener('sync', (event) => {  if (event.tag === 'flush-outbox') event.waitUntil(flushOutbox());});async function flushOutbox() {  const db = await openQueueDb();  const items = await db.getAll('outbox');  for (const item of items) {    try {      const res = await fetch('/api/reports', {        method: 'POST',        headers: { 'Content-Type': 'application/json', 'Idempotency-Key': item.id },        body: JSON.stringify(item),      });      if (res.ok) await db.delete('outbox', item.id);    } catch {      break; // still offline — leave the rest for the next sync    }  }}

Three field-earned details. First, send an idempotency key on every queued mutation, because the sync will retry and you do not want three copies of the same order landing when a user rode an elevator through two dead zones — the server uses that key to dedupe. Second, design the drain so a mid-queue failure leaves the rest of the queue intact; I lost a batch once to a naive Promise.all that dropped everything when item four returned a 500. Third, decide what happens to a permanently failing item — a 400 that will never succeed. If you only break on network errors and delete on res.ok, a poison message sits in the outbox forever, blocking everything behind it. Give each item a retry count and move it to a dead-letter store after N attempts so one bad record can't wedge the whole queue.

Because Background Sync does not exist on iOS Safari, treat it as a progressive enhancement, not a foundation. The fallback path — flushing the outbox on the next online event or the next app open — has to be a first-class code path you actually test, not a comment that says "hi, iOS."

The capability gap: what the web still can't do

This is where PWA evangelism gets dishonest, so I'll be blunt. The web platform is capable, but it is not native, and the gap is not evenly distributed — it is an iOS-shaped cliff.

On Android/Chromium, a PWA can do a startling amount: push notifications, background sync, Web Bluetooth, file system access, contact picker, screen wake lock. On iOS Safari, a lot of that is missing, throttled, or deliberately withheld. Background Sync: not there. Web Bluetooth: not there. Web Push exists now, but only for PWAs the user has added to the home screen, and it arrived far later than on Android. Installed-PWA storage on iOS has a history of being evicted when the OS wants the space back, which for an offline-first app is the difference between a tool and a liability.

The honest table I keep in my head:

| Capability | Android / Chrome | iOS Safari |

| --- | --- | --- |

| Add to home screen | Yes, promptable | Yes, manual only |

| Background Sync | Yes | No |

| Web Push | Yes | Installed PWA only, recent |

| Web Bluetooth | Yes | No |

| File System Access | Yes | No |

| Reliable offline storage | Yes | Can be evicted |

Treat this as a "check the current state before you commit" table, not gospel — Safari does ship new capabilities, just slowly and with caveats. The discipline that matters is testing the specific API your product depends on, on the specific iOS version your users run, before you promise anything. I have watched a plan die on the discovery that the one API the product needed was Chromium-only. On iOS, a "PWA" that leans on background sync degrades to a website that forgets what the user did — which is worse than a website that never promised.

A decision framework: PWA, wrapper, or nothing

Here is the flowchart I run in my head before committing a team's weeks to this.

Step 1 — Does it clear one of the three bars? Offline reliability, background sync, or hardware access. If no: build a fast responsive website. Ship it. Go home. You just saved yourself a service worker and a support queue.

Step 2 — If yes, does the capability actually exist on your users' platforms? Check iOS specifically. If the killer capability is Chromium-only and half your users are on iPhones, the web won't carry you.

Step 3 — If the capability isn't on the web but you still want one codebase, wrap it. A Capacitor or Trusted Web Activity (TWA) wrapper gives you native APIs and app-store presence over your web UI. This is often the right answer people skip because "PWA" sounds purer. It isn't purer, it's just more restricted. A wrapper lets you keep the web codebase and reach a native plugin exactly where the platform falls short — the pragmatic middle path between a pure PWA and a full native rewrite.

Step 4 — If you need deep native performance or platform-specific UX, stop pretending. Build native, or reach for Flutter — which is where most of my mobile work lives anyway, precisely because it doesn't make me negotiate with Safari over whether I'm allowed to use a sensor.

| Your situation | Build this |

| --- | --- |

| No offline/sync/hardware need | Fast responsive website |

| Offline/sync/hardware, capability on web, all platforms | PWA |

| Need native APIs, want one web codebase | Capacitor / TWA wrapper |

| Deep native perf or platform UX | Native or Flutter |

Key takeaways