
PWA Performance Patterns — How to Make Progressive Web Apps Feel Instant
The service worker, App Shell, caching and runtime patterns that actually move retention numbers in a Progressive Web App. A field guide based on the patterns we ship for production PWAs.
A Progressive Web App is only as good as how it feels on the second visit. Cold-start performance gets attention; warm-start performance is where retention is won. This is the field guide we use when shipping production PWAs — the service worker patterns, App Shell discipline, and caching strategies that actually change behaviour, not just Lighthouse scores.
The mental model: cold, warm, offline
Three states matter for PWA performance:
- Cold start — first visit, empty cache. Optimise for time-to-interactive on real mobile.
- Warm start — repeat visit. Should feel like opening a native app: instant shell, fresh data streamed in.
- Offline / flaky — train, basement, bad hotel Wi-Fi. The PWA should still work for the things it sensibly can.
Each state needs a different strategy. The mistake teams make is optimising one at the expense of the other two.
App Shell, properly
The App Shell pattern is older than most current frontend frameworks but still wins on warm-start. Cache the static skeleton — header, navigation, layout chrome, brand fonts — separately from data. On a return visit, the shell paints from cache in < 100ms while data fetches in parallel.
What goes in the shell:
- The HTML skeleton for your default route.
- Critical CSS.
- The minimum JS needed to bootstrap routing.
- Brand assets that don't change between sessions.
What does not go in the shell: anything personalised, anything time-sensitive, anything large.
Service worker caching strategies — pick on purpose
The four strategies you'll actually use:
Cache-first
Best for fonts, icons, app shell, versioned static assets. Fast and offline-friendly. The trap: stale assets unless you version filenames.
Network-first
Best for HTML and JSON where freshness matters but offline is a fallback. Add a short timeout (e.g. 2s) before falling back to cache.
Stale-while-revalidate
Default for most user-generated and read-mostly data. Serve the cached copy immediately, refresh in the background. Feels instant; eventually consistent.
Network-only
For mutations and analytics — never cache. Use Background Sync for offline mutations.
Mix and match by URL pattern. Workbox makes this clean; if you're rolling your own, route by request type and don't be clever.
The cache versioning trap
Most "my PWA serves stale code" bugs come from one mistake: caching HTML or JS files without a version-aware update strategy. Two rules that prevent it:
- Use hashed filenames for JS and CSS. Cache them aggressively.
- Use network-first for HTML with a short timeout, and bump the cache version on every deploy.
Listen for updatefound on the service worker registration and surface a "new version available — reload" toast. Don't silently force-reload — it interrupts users mid-task.
Make warm starts genuinely instant
- Pre-cache the shell on install. Don't wait for the user to navigate to the route.
- Pre-fetch the next likely route on idle (e.g. hover or viewport-edge intersection).
- Persist the last successful API response per route — render it instantly while a fresh fetch streams.
- Use
view-transitionswhere supported to mask the swap.
Background sync for resilient writes
If the user creates a thing and the network drops, queue the mutation in IndexedDB and replay it via the Background Sync API. Combined with optimistic UI, this is what turns a PWA from "nice on the desktop" into a "works on the train" app.
Push notifications without being a jerk
Two rules: don't ask for permission until the user has done something that earns the right, and never use notifications for marketing on day one. PWA permission prompts are non-renewable — burn it once and you're done.
Measuring what matters
Lighthouse is fine for regression; real-user metrics are how you understand retention. Capture:
- LCP, INP and CLS per route on real users.
- Service worker hit rate.
- Offline session count and offline mutation queue depth.
- Update flow drop-off — how many users see "new version" and never reload.
PWA vs native app — when does PWA actually win?
PWAs win when: you want a single codebase, you don't need deep platform features, distribution should be a URL not an app store review, and your target users have decent modern phones. PWAs lose when: you need tight platform integration (CallKit, HealthKit, deep camera control), you want to be in the app store discovery surface, or you need every last frame of native rendering for games or AR.
Most B2B and content products are squarely in PWA territory; most consumer media products land somewhere in between. Our mobile app team ships both — talk to us if you're trying to make the call.
The five-minute PWA performance audit
- Open DevTools → Application → Service Workers. Is one registered? Is it stale?
- Reload twice. Does the second load paint the shell in < 200ms?
- Toggle "Offline" in DevTools. Does the app degrade gracefully or just break?
- Run Lighthouse mobile. PWA category installable, performance > 90.
- Throttle to "Slow 4G" and try a write action. Does it queue or fail loudly?
Want a deeper look at your PWA? Book a free consultation with our team.
Frequently Asked Questions
What is a Progressive Web App (PWA)?
A Progressive Web App is a website that uses modern browser capabilities — service workers, manifest, push notifications, background sync — to deliver an app-like experience. Users can install it to the home screen, use it offline, and receive push notifications, all from a single web codebase.
Which service worker caching strategy should I use?
Use cache-first for static assets and the app shell, network-first with a short timeout for HTML and JSON where freshness matters, stale-while-revalidate as the default for read-mostly data, and network-only for mutations and analytics. Mix strategies by URL pattern.
PWA vs native app — which should I build?
Choose a PWA when you want a single codebase, URL-based distribution, and don't need deep native platform features. Choose native when you need tight OS integration (CallKit, HealthKit, advanced camera/AR), heavy game-style rendering, or app-store discovery as a primary growth channel.
How do I avoid serving stale code from a PWA?
Use hashed filenames for JS and CSS so they can be cached aggressively, use a network-first strategy with a short timeout for HTML, bump the service worker version on every deploy, and surface a non-intrusive 'new version available' prompt rather than silently force-reloading the page.