App Shell Architecture
The app-shell architecture
Section titled “The app-shell architecture”The app-shell architecture is a design pattern that splits a PWA into two distinct layers:
- The shell — minimal HTML, CSS, and JavaScript that renders the application frame (navigation, layout, header, footer). It changes only when you deploy a new version.
- The content — the data and markup specific to each view (articles, products, user profile). It changes frequently and is fetched at runtime.
By separating these two layers, you can aggressively precache the shell and use a lighter-weight strategy for content. The result: repeat visits load the UI frame from local storage in milliseconds, and content fills in asynchronously.
What belongs in the shell
Section titled “What belongs in the shell”The shell should contain everything needed to render a meaningful skeleton without any network request:
index.html — the application entry pointapp.css — layout, typography, navigation stylesapp.js — the client-side router and bootstrap codeicons/logo.svg — brand assets used on every pageoffline.html — fallback page for failed navigationsIt should NOT contain per-page data, user-specific content, or assets that change between page views.
Precaching the shell on install
Section titled “Precaching the shell on install”const SHELL_CACHE = 'shell-v2';const SHELL_ASSETS = [ '/', '/app.css', '/app.js', '/icons/logo.svg', '/offline.html',];
self.addEventListener('install', (event) => { event.waitUntil( caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS)) ); self.skipWaiting();});cache.addAll() is atomic — if any asset fails to download, the entire install fails and the old SW keeps running. Keep the precache list small and reliable.
Cache the shell, network the content
Section titled “Cache the shell, network the content”In the fetch handler, distinguish shell requests from content requests and apply a different strategy to each.
const CONTENT_CACHE = 'content-v1';
self.addEventListener('fetch', (event) => { const url = new URL(event.request.url);
// Shell assets: cache-first (fast, offline-safe) if (SHELL_ASSETS.includes(url.pathname)) { event.respondWith( caches.match(event.request).then((cached) => cached || fetch(event.request)) ); return; }
// API / content: network-first, fall back to cache if (url.pathname.startsWith('/api/')) { event.respondWith( fetch(event.request) .then((response) => { const clone = response.clone(); caches.open(CONTENT_CACHE).then((cache) => cache.put(event.request, clone)); return response; }) .catch(() => caches.match(event.request)) ); return; }
// Navigation: serve shell, let the SPA router handle the route if (event.request.mode === 'navigate') { event.respondWith( caches.match('/').then((cached) => cached || fetch(event.request)) ); }});Why the SPA pattern matters here
Section titled “Why the SPA pattern matters here”In a Single Page Application, the server only has one HTML file (index.html / /). All routing happens in JavaScript. When the service worker intercepts a navigation to /profile or /settings, it should serve the same index.html shell — the SPA router will read the URL and render the correct view. Without this, navigating to a deep link offline would return a 404 from cache instead of the shell.
Load sequence diagram
Section titled “Load sequence diagram”sequenceDiagram
participant U as User
participant SW as Service Worker
participant CS as Cache Storage
participant NET as Network
U->>SW: navigate to /dashboard
SW->>CS: match('/')
CS-->>SW: shell (index.html) — instant
SW-->>U: Shell renders in <100ms
U->>SW: fetch /api/dashboard-data
SW->>NET: fetch (network-first)
NET-->>SW: JSON response
SW->>CS: cache.put (content-v1)
SW-->>U: Data fills in Updating the shell
Section titled “Updating the shell”Because shell assets are cached aggressively, you need a strategy to push updates. The standard approach is to change the cache name (e.g., shell-v2 → shell-v3) and clean up old caches in the activate event.
self.addEventListener('activate', (event) => { const keepCaches = [SHELL_CACHE, CONTENT_CACHE]; event.waitUntil( caches.keys().then((names) => Promise.all( names .filter((name) => !keepCaches.includes(name)) .map((name) => caches.delete(name)) ) ).then(() => self.clients.claim()) );});