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-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())
);
});
Runs a real service worker + manifest in your browser.
OptionBenefitCost
App-shell architectureUI shell loads instantly from cache even offline, then data fills inRequires separating shell from content at design time, adding build complexity
Fully server-rendered on every requestSimpler structure, no shell/content split neededNothing to show at all if the network fails, since the whole page depends on one response
  • Caching only the shell but forgetting that dynamic content needs its own fallback too — otherwise offline shows an empty shell with no data.
  • Making the shell too large — almost the whole page — which defeats the point of separating shell from content.
  • Not versioning the shell cache, so after a new deploy the old shell lingers and users see old UI mixed with new data.

Twitter Lite’s PWA rebuild is a documented real-world example of this exact split: shell (navigation, layout) separated from timeline content, so the app opens instantly even on a slow connection before tweets load in (web.dev/twitter).

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?