The term "PWA" gets used loosely enough that it's worth being precise about what we mean. A manifest file and a service worker that only caches your app shell will get you an install icon, but it won't get you an app that survives a dropped connection mid-session. That distinction mattered a lot when we built Cricket Clash, a live scoring app used at cricket grounds where signal is often patchy at best.

Why offline-first, not just installable

"Installable" is a marketing feature. "Offline-first" is an architecture decision, and it needs to be made before you write your first API call, not bolted on afterward. The core idea is simple: the app should read from and write to a local store first, and treat the network as a background sync target rather than the source of truth for every interaction.

For Cricket Clash, this meant every scoring action — a run scored, a wicket taken — writes immediately to IndexedDB and updates the UI instantly. The network request to Supabase happens afterward, and if it fails, the action sits in a queue and retries. The scorer at the ground never sees a spinner or an error state for a dropped connection; the app just quietly catches up once signal returns.

Getting the service worker lifecycle right

Most of the confusing bugs we've debugged in PWAs come down to a misunderstanding of the service worker lifecycle, specifically the gap between when a new service worker installs and when it actually takes control of open pages. A newly deployed service worker doesn't activate immediately by default — it waits until every open tab running the old version has closed.

For most content sites that's fine. For an app someone might leave open for a three-hour cricket match, it's a problem: users can get stuck on stale JavaScript for hours. We solve this with a combination of skipWaiting() on install and a small in-app banner that prompts a refresh when a new version is detected, rather than force-reloading a page mid-interaction and losing state.

// simplified activation flow self.addEventListener('install', event => { self.skipWaiting(); }); self.addEventListener('activate', event => { event.waitUntil(self.clients.claim()); });

Choosing a caching strategy per resource

Treating every request with the same caching strategy is the most common mistake we see in PWA implementations. Different resources need different rules:

  • App shell (HTML, CSS, core JS): cache-first, with a background revalidation so updates arrive without blocking the current session.
  • User-generated data (scores, drafts, journal entries): network-first with an IndexedDB fallback, never cache-first, since stale data here is actively misleading.
  • Images and static assets: cache-first with a long expiry, since these rarely change and re-fetching wastes bandwidth on a slow connection.
  • Third-party API calls you don't control: network-only, or stale-while-revalidate at most, since caching someone else's data indefinitely can create confusing states.

Getting this wrong in either direction causes real problems. Cache too aggressively and users see outdated data with no indication it's stale. Cache too little and the "offline" part of your offline-first app stops meaning anything.

Ad Placement (Placeholder)

Syncing data once the connection returns

The hardest part of offline-first isn't caching — it's reconciliation. Once a device reconnects, you need a clear answer to "what happens when two people scored the same over from two different phones while offline?" For Cricket Clash, we handle this with a simple rule: the device with the earliest local timestamp wins for conflicting fields, and any true conflict gets flagged for a human to resolve rather than silently overwritten.

This is one place we'd caution against over-engineering. Full operational-transform or CRDT-based sync sounds appealing, but for most apps a simple last-write-wins-with-a-flag approach handles 95% of real cases with a fraction of the complexity, and complexity is exactly what you don't want in code that only runs during the tricky edge case of a reconnect.

The install prompt, done politely

Browsers fire a beforeinstallprompt event you can capture and trigger later, and it's tempting to show it the moment it's available. We've found conversion is meaningfully higher when the prompt appears after a real moment of value — after someone finishes scoring their first match, for instance — rather than on page load before they've done anything.

// capture and defer the native prompt let deferredPrompt; window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); deferredPrompt = e; }); // later, after a meaningful action: installButton.addEventListener('click', () => { deferredPrompt?.prompt(); });

Pitfalls we hit in production

A few lessons that cost us real debugging time, in the hope they save you some:

  • iOS Safari has historically been stricter about storage limits and background sync than Chrome — test on real iOS devices, not just simulators.
  • Aggressive cache-first strategies on JSON API responses can serve genuinely wrong data for days if you forget a cache-busting strategy tied to deploys.
  • IndexedDB transactions can silently fail under low storage; always surface a visible error state rather than assuming a write succeeded.
  • Users forget they're offline. A small, persistent connectivity indicator prevents a lot of confused support emails.
The goal of offline-first isn't "works with no internet." It's "never makes the user think about the internet at all."

Frequently Asked Questions

Yes, modern iOS Safari supports service workers, the manifest, and home-screen installation, though storage limits and some background sync APIs remain more restrictive than on Android.

No — service workers, the Cache API, and IndexedDB are all native browser APIs. We build most of our PWAs, including Cricket Clash and Ayori, in vanilla JavaScript.

Browser dev tools offline mode is a good start, but test on real devices with airplane mode and marginal signal — genuine flaky connectivity surfaces bugs that a clean offline toggle never will.


Related Posts