← Back to Blog

PWA Install and Offline Caching: What Actually Works in 2026

October 2026~14 min read

What Actually Makes an App Installable

"Installable PWA" sounds like a compliance checklist; in practice it is one specific browser behavior you're unlocking. Chromium shows the install affordance when it sees four things:

{
  "id": "/sand/",
  "name": "Falling Sand",
  "short_name": "Sand",
  "start_url": "/sand/?src=pwa",
  "display": "standalone",
  "background_color": "#0b0f14",
  "theme_color": "#0b0f14",
  "icons": [
    { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icons/maskable-512.png", "sizes": "512x512",
      "type": "image/png", "purpose": "maskable" }
  ]
}

Two details in that manifest earn their lines. The maskable icon is what makes the Android adaptive-icon treatment look intentional rather than a cropped, off-center logo; a maskable icon needs its content within the central safe circle, so it is usually a separate file from the regular 512. And start_url's ?src=pwa query is a free analytics channel: the only reliable way to count launches that came from the installed app rather than a browser tab.

The beforeinstallprompt Dance

Chromium fires beforeinstallprompt when the app passes the criteria and the browser's heuristics are satisfied. If you do nothing, the browser may show its own mini-infobar at a moment of its choosing. The standard pattern suppresses that and defers to your own UI — a button in your header or settings, shown at a moment you choose:

let deferredPrompt = null;

window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();              // suppress the browser's mini-infobar
  deferredPrompt = e;
  installButton.hidden = false;    // show OUR button, at OUR moment
});

installButton.addEventListener('click', async () => {
  if (!deferredPrompt) return;
  deferredPrompt.prompt();
  const { outcome } = await deferredPrompt.userChoice;  // 'accepted' | 'dismissed'
  deferredPrompt = null;           // the event is single-use either way
  installButton.hidden = true;
});

window.addEventListener('appinstalled', () => {
  // The only reliable "the user installed" signal
});

Three behaviors to design around, all of them undocumented-feeling and all of them real:

Detecting that you are running as the installed app is a different question, answered without any events:

const isStandalone =
  window.matchMedia('(display-mode: standalone)').matches ||
  window.navigator.standalone === true;   // legacy iOS property
if (isStandalone) document.body.classList.add('pwa-mode');
// CSS: @media (display-mode: standalone) { .install-cta { display: none; } }

Using this to hide the install CTA everywhere at once is the single highest-value three lines of PWA polish — installed users should never be told to install.

iOS Safari: The Rules Are Different

Everything above describes Chromium. On iOS, beforeinstallprompt does not exist — not throttled, not gated: absent. There is no prompt API, no programmatic install, and no per-app install analytics event. The only path is the user doing Share → Add to Home Screen themselves, which means the only correct implementation is instructions:

const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
  || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // iPadOS masquerades as Mac
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
  || navigator.standalone === true;

if (isIOS && !isStandalone && !('onbeforeinstallprompt' in window)) {
  showIOSInstructions();   // 'Tap Share, then Add to Home Screen'
}

The iPadOS user-agent masquerade on the second line is not paranoia — an iPad Safari UA reports MacIntel, and the touch-points check is the accepted way to tell it apart from a laptop.

Two iOS behaviors that shape product decisions, not just code. First: the installed home-screen app and Safari use separate storage containers. Data a user accumulates in Safari (scores, notes, saved items) does not carry into the installed app, and vice versa. If your app accumulates meaningful local data, prompt for install before the user's data grows, or ship export/import — "install it and lose everything so far" is a support-ticket generator. Second: Safari's 7-day storage eviction (the ITP cap on script-writable storage for sites without recent interaction) does not apply to home-screen apps — an installed web app's storage persists. So on iOS, "add to home screen" is not a nicety; it is the difference between an app that keeps the user's data and a tab that may wake up empty. Scheduling, offline tools, anything with local state — say so in the install instructions, because it's true and it's the best argument the platform gives you.

Also budget for the video question up front: iOS supports SW-based offline caching and the Cache API, so the caching architecture below works unchanged. Web Push on iOS exists for installed web apps only (since 16.4) — another reason install-first matters if you plan reminders.

Precache vs Runtime: The Two-Layer Cache

Every sane offline strategy is two layers with different personalities. The precache is the app shell — HTML, CSS, JS, icons — fetched at install time, versioned, and treated as a unit: it changes only when the app version changes. Runtime caches hold everything discovered while the app runs — API responses, user-authored content, fonts — and evolve continuously with their own strategy per resource type. Mixing the two — dumping runtime responses into the precache — is why some offline apps serve a year-old page shell next to this morning's data.

Versioned names make cleanup automatic. Bump a string, and old caches are orphaned for the activate handler to delete:

// sw.js
const VERSION = 'v42';                       // bump on every deploy
const PRECACHE = `precache-${VERSION}`;
const RUNTIME  = `runtime-${VERSION}`;

const SHELL = [                              // build-generated, kept small
  '/', '/index.html', '/app.css', '/app.9f2c4e1a.js',
  '/manifest.webmanifest', '/icons/192.png', '/icons/512.png',
];

self.addEventListener('install', (event) => {
  event.waitUntil((async () => {
    const cache = await caches.open(PRECACHE);
    // addAll() rejects WHOLESALE if any single URL 404s — add individually
    await Promise.all(SHELL.map(async (url) => {
      try {
        await cache.add(new Request(url, { cache: 'reload' }));
      } catch (err) {
        console.warn('precache miss:', url, err);
      }
    }));
  })());
});

self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    const keys = await caches.keys();
    await Promise.all(
      keys.filter(k => k !== PRECACHE && k !== RUNTIME)
          .map(k => caches.delete(k))
    );
    await self.clients.claim();
  })());
});

Two lines in there are the ones people learn from production. addAll is all-or-nothing: one stale URL in the shell list and the install fails, the SW goes redundant, and offline silently never works — individual add with a logged failure keeps a single bad entry from taking down the feature. And cache: 'reload' bypasses the HTTP cache during precaching, so a new SW version doesn't precache HTTP-cached old assets — the exact mechanism behind "I deployed, but some users got the old bundle for a day".

The fetch router assigns strategies by request kind:

self.addEventListener('fetch', (event) => {
  const { request } = event;
  if (request.method !== 'GET') return;              // never cache mutations
  const url = new URL(request.url);
  if (url.origin !== location.origin) return;        // cross-origin: leave to the network

  // Navigations: network-first, fall back to the cached shell
  if (request.mode === 'navigate') {
    event.respondWith((async () => {
      try {
        const fresh = await fetch(request);
        const cache = await caches.open(PRECACHE);
        cache.put('/index.html', fresh.clone());     // keep the shell fresh
        return fresh;
      } catch {
        return (await caches.match('/index.html')) ?? Response.error();
      }
    })());
    return;
  }

  // Hashed static assets: cache-first (filenames change when content does)
  event.respondWith((async () => {
    const hit = await caches.match(request);
    if (hit) return hit;
    const fresh = await fetch(request);
    if (fresh.ok) {
      const cache = await caches.open(RUNTIME);
      cache.put(request, fresh.clone());
    }
    return fresh;
  })());
});

The shape rewards a second look. Navigations go network-first so users on a good connection never see a stale page, with the cached shell as the offline floor — for a multi-page site, cache each page's URL and fall back to a dedicated /offline.html instead. Static assets go cache-first and only cache-first because their filenames carry a content hash: app.9f2c4e1a.js can never be stale, so there is nothing to revalidate. If your build doesn't hash filenames, don't copy this half — you need stale-while-revalidate for those assets, or you will ship the "deployed but users run old JS" bug the cache: 'reload' line was fighting. And the fresh.ok guard quietly keeps opaque cross-origin responses (status 0, ok === false) out of the cache; Chromium pads opaque responses to multi-megabyte effective sizes for quota accounting, so even "free" third-party images can blow the storage budget.

One thing the code above deliberately does not do: skipWaiting() on install. Auto-activating a new SW takes control away from pages that are mid-session, and if the new version expects migrated data (an IndexedDB schema change is the classic), an uncontrolled takeover can corrupt state. Ship the waiting behavior with a UI prompt — the SW posts "update ready", the app shows a reload button, the button sends SKIP_WAITING — and users update on their terms.

Storage That Survives: Persistence and Eviction

Offline code does not matter if the browser deletes its storage. Chromium runs an eviction policy: "best effort" origins (the default) can have their Cache Storage and IndexedDB cleared under disk pressure — LRU-ish, no user prompt. "Persistent" origins are only cleared by user action. You request the upgrade with navigator.storage.persist(), and the moment to ask is the install flow, where the user intent ("I want this app on my device") matches what you're asking for:

async function ensurePersistentStorage() {
  if (!navigator.storage?.persist) return false;      // Safari: API absent, see below
  if (await navigator.storage.persisted()) return true;
  return navigator.storage.persist();                 // may resolve false
}

// An installed PWA in Chromium is usually granted automatically;
// for browser-tab usage it depends on engagement heuristics.
// Either way, asking is free and the answer is knowable:
const { quota, usage } = await navigator.storage.estimate();
const freeMB = Math.round((quota - usage) / 1048576);

The estimate() numbers are the raw material for honest UI: when an offline map download or media cache is about to consume a large share of quota, show "this will use about 300 MB of an estimated 4 GB available" before the user commits, not after the write fails. On Safari the persist() API is a no-op (absent), but installed home-screen apps effectively keep storage as described above — same product guidance, different mechanism.

Finally, separate your storage by purpose. Code and static assets belong in Cache Storage because you control their versioning and you want them replaced wholesale on deploy. User data belongs in IndexedDB (or OPFS/SQLite for structured data), because it must survive those wholesale replacements. An offline app that stores user notes in a versioned cache named by deploy is an app that deletes user notes on its next release. If it matters to the user, it does not live in the cache you clear.

Testing Offline Like You Mean It

Offline behavior is invisible in normal development, so it gets tested — when it gets tested — in exactly the ways that can't catch its real bugs. The workflow that does catch them:

The one habit that makes all of this cheap: keep the precache list small and generated by the build, so "what's cached" is a build artifact, not a hand-maintained list that drifts from reality the first busy week.

Production Checklist

  1. HTTPS, manifest with name/start_url/display, 192+512 icons, and a stable id — set before the first install ever happens.
  2. Feature-detect beforeinstallprompt; defer the event, show your own button, treat the event as single-use.
  3. Never gate functionality on the install event — provide manual instructions as the baseline; dedicated iOS instructions with the iPadOS UA check.
  4. Hide install CTAs with display-mode: standalone detection; demo mode class for styling.
  5. Prompt for install before local data accumulation on iOS; ship export/import where install can't be assumed.
  6. Two-layer caching: versioned precache for the shell, strategy-per-type runtime caches for the rest.
  7. Precache URLs individually with cache: 'reload'; never addAll for anything that can 404.
  8. Cache-first only for hash-named assets; stale-while-revalidate for unhashed; never cache non-GET; skip opaque cross-origin responses.
  9. Navigation fallback to the shell (SPA) or a real /offline.html (multi-page).
  10. No blind skipWaiting() — surface update-ready UI and let the user reload; never auto-activate across schema changes.
  11. Request navigator.storage.persist() during install; show estimate() numbers before large downloads; keep user data out of versioned caches.
  12. Test the two-step cold start, a slow-flaky network, and one real deploy-then-update cycle before calling it done.

References

Related Articles