Skip to content

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.

The shell should contain everything needed to render a meaningful skeleton without any network request:

index.html — the application entry point
app.css — layout, typography, navigation styles
app.js — the client-side router and bootstrap code
icons/logo.svg — brand assets used on every page
offline.html — fallback page for failed navigations

It should NOT contain per-page data, user-specific content, or assets that change between page views.

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.

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))
);
}
});

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.

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
App shell load vs content load sequence

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-v2shell-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())
);
});
Runs a real service worker + manifest in your browser.
Which assets belong in the precached app shell?
Why should you serve the shell (index.html) for all navigation requests in a SPA?
What is the consequence of including a large or unreliable asset in cache.addAll() during install?
How do you push a shell update to users who have the old version cached?