Precaching the App Shell for Offline Sharing

This guide is part of Service Worker Caching Strategies for Share PWAs, which itself is part of Offline-First PWA Patterns for Web Share API.

The app shell is the minimal set of files that lets your share button and queued-share list paint with no network at all. Precaching it during the service worker install event is what turns a share app into an offline-first one: on a cold launch in Airplane Mode, the shell is served from Cache Storage and the UI is interactive before the device even attempts a request. This guide covers exactly what to precache, why cache.addAll is all-or-nothing, and how versioned cache names keep the shell fresh across deploys.

Feature Detection Gate

Precaching only makes sense where service workers exist and the origin is a secure context. Gate registration before anything else runs, so insecure or unsupported environments fall straight through to your non-offline path.

// register-sw.js
export async function registerShellWorker() {
  if (!('serviceWorker' in navigator)) return null;
  if (!window.isSecureContext) return null; // SW requires HTTPS (localhost exempt)

  try {
    return await navigator.serviceWorker.register('/sw.js');
  } catch (error) {
    console.error('Shell worker registration failed:', error.message);
    return null;
  }
}

Inside the worker itself there is no window; use self.isSecureContext if you need the same guard there. Registration failing is not fatal — the app still works online, it just loses its offline shell.

Solution Walkthrough

Step 1 — List only the minimal shell

The shell is a budget, not a manifest of everything you ship. Every entry is downloaded and stored before the worker activates, so include only what is required to render the share button and the queue UI. Leave user data, share-landing content, and rarely used routes to runtime caching.

// shell-manifest.js
export const SHELL_VERSION = 'shell-v4';

export const SHELL_ASSETS = [
  '/',                          // app entry (navigation fallback)
  '/index.html',
  '/assets/css/main.css',       // styles the share button + queue list
  '/assets/js/share-ui.js',     // renders the share affordance
  '/assets/js/share-queue.js',  // draws the queued-share count
  '/assets/icons/share-192.png',
  '/assets/icons/share-512.png',
  '/offline.html'               // fallback for uncached navigations
];

Deriving one SHELL_VERSION constant is the hinge of the whole pattern: the cache name, the install target, and the activate cleanup all read from it, so a single edit rotates the entire shell.

Step 2 — Precache atomically on install

Open a cache named for the current version and hand cache.addAll the full list, wrapped in event.waitUntil. addAll fetches every URL and commits only if all succeed — a single missing file rejects the promise and fails the install, so users never get a half-cached shell that renders broken.

// sw.js — install
import { SHELL_VERSION, SHELL_ASSETS } from './shell-manifest.js';

self.addEventListener('install', function onInstall(event) {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(SHELL_VERSION);
      // Atomic: any single non-OK response aborts the whole precache.
      await cache.addAll(SHELL_ASSETS);
    })()
  );
});

If you need one asset to be non-blocking (say, a large optional icon), pull it out of addAll and add it separately with an individual cache.add wrapped in its own try/catch — that failure then will not sink the critical shell.

Step 3 — Version, then claim on activate

When the worker activates, delete any cache that is not the current shell version and claim open clients so they are controlled immediately. This is where a stale shell is retired.

// sw.js — activate
import { SHELL_VERSION } from './shell-manifest.js';

self.addEventListener('activate', function onActivate(event) {
  event.waitUntil(
    (async () => {
      const names = await caches.keys();
      await Promise.all(
        names
          .filter((name) => name !== SHELL_VERSION)
          .map((name) => caches.delete(name))
      );
      await self.clients.claim();
    })()
  );
});

// Activate a new shell only when the page asks (see the cluster's update flow).
self.addEventListener('message', function onMessage(event) {
  if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
});

Serving the shell then means answering navigations and asset requests from SHELL_VERSION first, exactly as the cache-first branch in the service worker caching strategies guide does. Keep skipWaiting message-driven rather than unconditional so an updated shell never swaps in under an active session.

Failure Modes and Recovery

Symptom Root cause Minimal fix
Install never completes; worker stuck installing One URL in SHELL_ASSETS returns 404/redirect/500 — addAll rejected Verify every path resolves to a 200; move optional assets out of addAll
Old UI still shows after deploy Cache name unchanged, so install reused the old cache Bump SHELL_VERSION on every shell-affecting deploy
sw.js update never discovered Worker script served with a long max-age Serve sw.js with Cache-Control: no-cache so the browser revalidates it
Storage bloats each release Old versioned caches never deleted Filter-and-delete non-current caches in activate (Step 3)
Shell present but blank content Over-precached HTML holds stale dynamic data Keep dynamic data out of the shell; use runtime caching instead
Quota errors on low-end devices Shell is too large (fonts, hero images, whole bundle) Trim to the minimal render path; lazy-load the rest at runtime

The two failures that bite hardest are opposite errors of scope. Under-listing leaves the offline shell missing a stylesheet or script and rendering broken; over-listing bakes dynamic data into the precache, so users see a stale shared-item list until the next version ships. Precache the skeleton, never the contents.

Browser and Platform Caveat

Precaching works wherever service workers do — Chromium, Firefox, and Safari 16.4+ all honour install-time cache.addAll. The platform difference is persistence, not capability: iOS Safari evicts Cache Storage for origins it treats as idle, so a precached shell can silently vanish between launches. Design for it by making the shell reconstructible — the cache-first fetch handler must fall through to the network on a miss — and by keeping anything that must survive, like queued share payloads, in an IndexedDB share queue with navigator.storage.persist() rather than in the response cache.

FAQ

Why does one 404 break my entire precache?

cache.addAll is atomic: it fetches every URL and commits only if all succeed. A single 404, redirect, or other non-200 response rejects the returned promise, event.waitUntil sees a rejection, and the install fails so the worker never activates. This is deliberate — it prevents a half-populated shell that would render broken offline. Move genuinely optional assets to individual cache.add calls with their own error handling.

What belongs in the app shell versus runtime caching?

The shell is only the static skeleton needed to draw the share button and queue UI with no network: the HTML frame, CSS, core JS, and icons. User-specific or dynamic content — shared-item lists, link previews, share-landing HTML — belongs in runtime caching so it stays fresh and does not bloat the install. A good test: if the value changes per user or per session, it is not shell.

Do I need skipWaiting for the shell to work?

No. skipWaiting only controls when a new worker takes over; the shell caches on install regardless. Call it when you want an updated shell to activate immediately, but gate it behind a user prompt to avoid loading new assets against a page rendered by the old shell. See the update flow in the caching strategies guide for the safe pattern.

How do I stop a stale shell showing after deploy?

Change the cache version string on every deploy that touches shell assets. A new version name forces install to precache fresh copies, and the activate cleanup deletes the old cache. Serve sw.js itself with Cache-Control: no-cache so the browser re-checks it on each navigation and discovers the new version.