Progressive Web Apps

When a PWA actually beats native, when it loses, and the service worker, Workbox, Background Sync, and Web Push details I keep getting bitten by.

A teammate dropped a thread in our mobile squad last spring. We were deep in the branded-mobile-app pipeline at the creator economy platform I worked at, burning a Rails plus Python plus Fastlane orchestration to ship native iOS and Android binaries for thousands of creators. The thread said, basically: why are we doing this. A PWA would cover most of what these apps actually do. Fair question. I went and looked.

PWAs are better than people remember. They are still not native, and the gap is in places that matter for paid products. So I want to be specific about where I’d reach for one, where I wouldn’t, and what the bits inside actually look like in production.

When a PWA actually wins

Install friction is the bottleneck for almost every consumer mobile product. The TikTok-to-App-Store-to-launch flow loses people at every step. A web URL that prompts “Add to Home Screen” after a real session is a quietly better funnel than most teams admit.

So my rule of thumb: if your product is a reading or browsing surface, an internal tool, or something where the user has already decided they want you and a friction-free first session matters more than App Store badge perception, PWA. If your product is paid, distribution-led, deeply integrated with system services, or sold as “an app” (the buyer is paying for the badge), native.

That second case is real. At the creator platform, our branded mobile apps had analytics surfaces creators rarely opened on a phone. We could have moved those to a PWA. We didn’t. The creators were paying for the existence of a native app on the store under their own brand. The PWA savings did not matter to that buyer.

Service worker lifecycle in practice

Most “weird PWA behavior” is the service worker lifecycle biting you. The basics, in order: install, activate, fetch. install populates caches. activate cleans up old caches and claims clients. fetch serves.

The two flags that cause the most pain are self.skipWaiting() and self.clients.claim(). Use them when you want a new SW to take over immediately. Don’t use them blindly, because a tab might be mid-flight on the old version and you’re about to swap its asset graph.

import { Workbox } from 'workbox-window'

export function registerServiceWorker(onUpdateAvailable: () => void) {
  if (!('serviceWorker' in navigator)) return

  const wb = new Workbox('/sw.js', { scope: '/' })

  wb.addEventListener('waiting', () => {
    onUpdateAvailable()
  })

  wb.addEventListener('controlling', () => {
    window.location.reload()
  })

  wb.register().catch((err) => {
    console.error('sw registration failed', err)
  })

  return {
    activatePending: async () => {
      wb.messageSkipWaiting()
    },
  }
}

Register the worker, listen for waiting, surface a small “new version available, refresh” toast, let the user opt in. Auto-swapping mid-session is how you end up with a checkout page where the JS bundle and the HTML disagree about what fields exist.

Service-worker caches work the same way as any other cache layer. If you change cache key naming, you’re shipping a schema change to a layer you don’t own, sitting on millions of devices. Cache keys are part of your public API. Treat any change to key composition like a schema migration.

Workbox strategies I reach for

Five named strategies. In practice I use three.

StaleWhileRevalidate for content APIs you’d rather show fast and a-bit-stale than slow and fresh. Comments, social feeds, anything where 30 seconds of staleness is fine.

NetworkFirst for auth, writes, and anything the user perceives as “this should reflect the latest”. With a short timeout fallback to cache, so an offline tab still renders something.

CacheFirst for hashed static assets. The hash in the filename is the cache key. Forever-cache it.

// workbox.config.ts
import { build } from 'workbox-build'

await build({
  globDirectory: 'dist/',
  globPatterns: ['**/*.{js,css,html,svg,woff2}'],
  swDest: 'dist/sw.js',
  runtimeCaching: [
    {
      urlPattern: ({ url }) => url.pathname.startsWith('/api/feed'),
      handler: 'StaleWhileRevalidate',
      options: {
        cacheName: 'feed-v3',
        expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 },
      },
    },
    {
      urlPattern: ({ url }) => url.pathname.startsWith('/api/auth') || url.pathname.startsWith('/api/checkout'),
      handler: 'NetworkFirst',
      options: {
        cacheName: 'critical-v3',
        networkTimeoutSeconds: 3,
      },
    },
    {
      urlPattern: /\.(?:js|css|woff2)$/,
      handler: 'CacheFirst',
      options: {
        cacheName: 'static-v3',
        expiration: { maxEntries: 500, maxAgeSeconds: 60 * 60 * 24 * 365 },
      },
    },
  ],
  skipWaiting: false,
  clientsClaim: false,
})

Note the -v3 suffix on every cache name. That’s the migration flag. When what we put in the cache changes, bump it. Old caches get evicted on activate. No clever invalidation logic. Just versioning.

Background sync and offline writes

Background Sync is the API I want to love. The pitch: queue failed writes, replay when connectivity returns. Reality: iOS Safari does not support it. Period. So whatever you build has to degrade to “retry on next foreground” without telling the user.

import { Queue } from 'workbox-background-sync'

const writeQueue = new Queue('writes', {
  maxRetentionTime: 60 * 24, // minutes; 24 hours
  onSync: async ({ queue }) => {
    let entry
    while ((entry = await queue.shiftRequest())) {
      try {
        const res = await fetch(entry.request.clone())
        if (!res.ok) throw new Error(`status ${res.status}`)
      } catch (err) {
        await queue.unshiftRequest(entry)
        throw err
      }
    }
  },
})

export async function postWithSync(url: string, body: unknown, idempotencyKey: string) {
  const req = new Request(url, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      'idempotency-key': idempotencyKey,
    },
    body: JSON.stringify(body),
  })

  try {
    const res = await fetch(req.clone())
    if (!res.ok) throw new Error(`status ${res.status}`)
    return res
  } catch (err) {
    await writeQueue.pushRequest({ request: req })
    throw err
  }
}

That idempotency-key is not decorative. It’s the difference between “background sync replayed three times” and “the user got billed three times”.

I learned the idempotency rule from native billing. At the creator economy platform I worked at, we’d shipped Apple In-App Purchase into branded apps so creators could sell subscriptions. Months later, a creator’s ticket: all my customers got charged twice this month and the app shows them as having two active subscriptions. Apple’s server-to-server SubscriptionRenewal notification had been retried after our endpoint returned a 200 OK just past its 30-second deadline. No idempotency check on the renewal handler. Every retry created a new subscription row. A few thousand customers across dozens of branded apps got double-billed. Apple had already charged the cards.

First wrong fix: a frontend tweak that showed only the latest subscription row per customer. Visible only. Apple did not refund anything because we hid a row. The creator escalated to legal.

Real fix: a Sidekiq job, a database unique constraint on (apple_original_transaction_id, notification_uuid), the endpoint returning 200 OK within five seconds by enqueueing the work asynchronously. Apple’s retries became idempotent at the queue level. Refunds coordinated through Apple’s developer support API, which took several days because each transaction needs per-transaction approval at that volume.

Same rule applies to Background Sync. The queue will replay. Your server has to assume it’s seeing the same write twice. The idempotency key is the contract.

Web Push and the iOS asterisk

Web Push works. VAPID for identity, encrypted payload, serviceWorker.pushManager.subscribe. The server side is small:

import webpush from 'web-push'
import type { Subscription } from './types'
import { db } from './db'

webpush.setVapidDetails(
  'mailto:[email protected]',
  process.env.VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!,
)

export async function sendPushNotification(sub: Subscription, payload: object) {
  try {
    await webpush.sendNotification(
      {
        endpoint: sub.endpoint,
        keys: { p256dh: sub.p256dh, auth: sub.auth },
      },
      JSON.stringify(payload),
      { TTL: 60, urgency: 'normal' },
    )
  } catch (err: any) {
    if (err.statusCode === 410 || err.statusCode === 404) {
      await db.subscriptions.delete({ where: { endpoint: sub.endpoint } })
      return
    }
    throw err
  }
}

The 410 handler is the part most people skip. Endpoints expire. Users uninstall. Browsers rotate keys. You will accumulate dead subs and start hitting your push budget for no reason.

The iOS asterisk: Web Push has landed on iOS Safari, but only for PWAs that the user has explicitly installed via “Add to Home Screen”. If the user drags that icon to the trash, the subscription dies silently. Your re-engagement strategy on iOS is, in practice, “be useful enough that they install you, and useful enough that they don’t uninstall you”. Native push has the same constraint, but the install step on native goes through the App Store, which carries its own trust signal.

When PWA loses, name it

Where PWA still loses:

  • App Store discoverability. If your acquisition channel is store search or featured placement, no PWA.
  • In-App Purchase. Apple and Google take their cut, and that is also the only way a chunk of consumers feel safe paying. Web checkout converts worse on mobile for paid content.
  • Deep system integration: background audio with lock-screen controls, complex Bluetooth (any wearable I’ve worked with for health and motion data), serious file system access, share-sheet behavior that feels native.
  • The trust signal. For paid products, “I bought it from the App Store” still beats “I added a web page to my home screen”, and that perception is built into pricing.

So my position: PWA is a strong default for content surfaces and internal tools. Don’t pick it because it lets you dodge native complexity. Pick it when install friction is your real bottleneck. If the buyer is paying for the badge, ship the badge.

Takeaways

  • PWA wins when install friction is the bottleneck. Reading surfaces, internal tools, dashboards.
  • Native wins on store discoverability, IAP, system integration, and the buyer’s perception of “having an app”.
  • Service worker cache keys are a public API. Version them, treat key composition changes like a schema migration.
  • Background Sync without idempotency keys is a duplicate-write generator. The key is the contract.
  • iOS Web Push exists, but only for installed PWAs, and dies silently when the icon goes away.
  • Don’t pick PWA to avoid native complexity. Pick it because it serves the user better.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.