Progressive Web Apps (PWA)
Web App Shell Architecture
Introduction to App Shell Architecture
The App Shell architecture is a design pattern that separates your application's core infrastructure (the "shell") from its dynamic content. This enables instant loading and reliable performance, even on slow networks.
Key Concept: The app shell is the minimal HTML, CSS, and JavaScript required to power the user interface. It's cached offline so that on repeat visits, the shell loads instantly while dynamic content loads progressively.
What is the App Shell?
The app shell consists of:
- Header/Navigation: Top bar, menu, branding
- Layout Structure: Main content area, sidebars, footers
- Loading States: Skeleton screens, spinners, placeholders
- Core Styles: CSS for the shell (not content-specific styles)
- Core Scripts: JavaScript for navigation and app logic
What is NOT in the app shell:
- Dynamic content (articles, posts, products)
- User-generated data
- Content-specific images
- API responses
Benefits of App Shell Architecture
- Instant Loading: Shell loads from cache immediately
- Offline Support: Users can navigate even without network
- Native-like Performance: Feels like a native app
- Reduced Bandwidth: Shell cached once, content updates separately
- Better UX: Users see something immediately, not a blank screen
App Shell vs Traditional Architecture
Traditional Server-Rendered Page
1. User requests /article/123
2. Server generates entire HTML page
3. Browser downloads everything (header, content, footer, styles, scripts)
4. Page renders
5. Next page request repeats entire process
App Shell Architecture
1. User visits first time
- Downloads and caches app shell
- Fetches content for current route
- Renders shell + content
2. User navigates to /article/123
- Shell loads instantly from cache
- Only content fetches from network
- Shell updates with new content
3. User goes offline
- Shell still works
- Cached content available
- Graceful degradation for uncached content
Implementing the App Shell
1. HTML Structure
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My PWA</title>
<!-- Critical CSS inline -->
<style>
/* App shell styles */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: sans-serif; }
.app-header {
background: #2196F3;
color: white;
padding: 1rem;
position: sticky;
top: 0;
z-index: 100;
}
.app-content {
min-height: calc(100vh - 120px);
padding: 1rem;
}
.app-footer {
background: #f5f5f5;
padding: 1rem;
text-align: center;
}
/* Skeleton screen */
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton-text {
height: 1rem;
margin-bottom: 0.5rem;
border-radius: 4px;
}
.skeleton-title {
height: 2rem;
width: 60%;
margin-bottom: 1rem;
border-radius: 4px;
}
</style>
<link rel="manifest" href="/manifest.json">
</head>
<body>
<!-- App Shell Structure -->
<header class="app-header">
<h1>My PWA</h1>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/articles">Articles</a>
</nav>
</header>
<main class="app-content" id="content">
<!-- Dynamic content loads here -->
<!-- Skeleton screen shown while loading -->
<div class="skeleton skeleton-title"></div>
<div class="skeleton skeleton-text"></div>
<div class="skeleton skeleton-text"></div>
<div class="skeleton skeleton-text"></div>
</main>
<footer class="app-footer">
<p>© 2024 My PWA</p>
</footer>
<script src="/js/app.js"></script>
</body>
</html>
2. App Shell JavaScript
// app.js - Core app logic
class App {
constructor() {
this.contentElement = document.getElementById('content');
this.init();
}
init() {
// Register service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
// Handle navigation
this.setupNavigation();
// Load content for current route
this.loadContent(window.location.pathname);
}
setupNavigation() {
// Intercept link clicks for client-side routing
document.addEventListener('click', (e) => {
if (e.target.tagName === 'A' && e.target.href.startsWith(window.location.origin)) {
e.preventDefault();
const url = new URL(e.target.href);
this.navigate(url.pathname);
}
});
// Handle browser back/forward
window.addEventListener('popstate', () => {
this.loadContent(window.location.pathname);
});
}
navigate(path) {
// Update URL without page reload
history.pushState(null, '', path);
// Load content for new route
this.loadContent(path);
}
async loadContent(path) {
try {
// Show skeleton while loading
this.showSkeleton();
// Fetch content (from cache or network)
const response = await fetch(`/api/content${path}`);
const data = await response.json();
// Render content
this.renderContent(data);
} catch (error) {
console.error('Error loading content:', error);
this.showError();
}
}
showSkeleton() {
this.contentElement.innerHTML = `
<div class="skeleton skeleton-title"></div>
<div class="skeleton skeleton-text"></div>
<div class="skeleton skeleton-text"></div>
<div class="skeleton skeleton-text"></div>
`;
}
renderContent(data) {
this.contentElement.innerHTML = `
<article>
<h1>${data.title}</h1>
<div>${data.content}</div>
</article>
`;
}
showError() {
this.contentElement.innerHTML = `
<div class="error">
<h2>Unable to Load Content</h2>
<p>Please check your connection and try again.</p>
</div>
`;
}
}
// Initialize app
new App();
3. Service Worker for App Shell
// sw.js - Service worker with app shell caching
const CACHE_NAME = 'app-shell-v1';
const APP_SHELL_FILES = [
'/',
'/index.html',
'/css/styles.css',
'/js/app.js',
'/images/logo.png',
'/offline.html'
];
// Install event - cache app shell
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('Caching app shell');
return cache.addAll(APP_SHELL_FILES);
})
);
self.skipWaiting();
});
// Activate event - cleanup old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
console.log('Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
self.clients.claim();
});
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
// Serve from cache
return response;
}
// Clone request for fetch
const fetchRequest = event.request.clone();
return fetch(fetchRequest).then((response) => {
// Check for valid response
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Clone response for cache
const responseToCache = response.clone();
// Cache API responses separately
if (event.request.url.includes('/api/')) {
caches.open('api-cache-v1').then((cache) => {
cache.put(event.request, responseToCache);
});
}
return response;
}).catch(() => {
// Network failed, serve offline page
if (event.request.mode === 'navigate') {
return caches.match('/offline.html');
}
});
})
);
});
Caching the App Shell
Precaching Strategy
// Precache app shell during service worker installation
const PRECACHE_URLS = [
// Core HTML
'/',
'/index.html',
// Styles
'/css/app-shell.css',
'/css/skeleton.css',
// Scripts
'/js/app.js',
'/js/router.js',
// Images
'/images/logo.svg',
'/images/icons/menu.svg',
// Fonts
'/fonts/roboto-regular.woff2',
// Offline fallback
'/offline.html'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('app-shell-v1').then((cache) => {
return cache.addAll(PRECACHE_URLS);
})
);
});
Update Strategy
// Update app shell when service worker updates
self.addEventListener('activate', (event) => {
const currentCaches = ['app-shell-v2', 'api-cache-v1'];
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (!currentCaches.includes(cacheName)) {
console.log('Deleting cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
});
// Notify user of updates
self.addEventListener('controllerchange', () => {
// Show update notification to user
if (navigator.serviceWorker.controller) {
console.log('App shell updated');
}
});
Dynamic Content Loading
Streaming Content Updates
// Load and display content progressively
async function loadArticle(articleId) {
try {
// Show shell immediately
showArticleShell();
// Fetch article data
const response = await fetch(`/api/articles/${articleId}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let article = '';
// Stream content as it arrives
while (true) {
const { done, value } = await reader.read();
if (done) break;
article += decoder.decode(value, { stream: true });
// Update UI progressively
updateArticleContent(article);
}
} catch (error) {
showError(error);
}
}
Optimistic Updates
// Show immediate feedback, sync in background
async function likeArticle(articleId) {
// Update UI immediately (optimistic)
updateLikeButton(articleId, true);
try {
// Send to server
await fetch(`/api/articles/${articleId}/like`, {
method: 'POST'
});
} catch (error) {
// Revert on failure
updateLikeButton(articleId, false);
showError('Failed to like article');
}
}
Skeleton Screens
Skeleton screens provide visual placeholders while content loads:
<!-- Article skeleton -->
<div class="article-skeleton">
<div class="skeleton skeleton-title"></div>
<div class="skeleton skeleton-meta"></div>
<div class="skeleton skeleton-image"></div>
<div class="skeleton skeleton-text"></div>
<div class="skeleton skeleton-text"></div>
<div class="skeleton skeleton-text short"></div>
</div>
/* Skeleton styles */
.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 0%,
#e0e0e0 50%,
#f0f0f0 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton-title {
height: 2.5rem;
width: 80%;
margin-bottom: 1rem;
}
.skeleton-meta {
height: 1rem;
width: 40%;
margin-bottom: 1.5rem;
}
.skeleton-image {
width: 100%;
height: 300px;
margin-bottom: 1.5rem;
}
.skeleton-text {
height: 1rem;
margin-bottom: 0.75rem;
}
.skeleton-text.short {
width: 60%;
}
Progressive Loading
Load content in stages for optimal perceived performance:
// Progressive loading strategy
async function loadPage() {
// Stage 1: Show shell immediately (from cache)
showAppShell();
// Stage 2: Load critical content
const criticalContent = await loadCriticalContent();
renderCriticalContent(criticalContent);
// Stage 3: Load secondary content (lazy)
requestIdleCallback(() => {
loadSecondaryContent().then(renderSecondaryContent);
});
// Stage 4: Prefetch next likely page
requestIdleCallback(() => {
prefetchNextPage();
});
}
function loadCriticalContent() {
return fetch('/api/content/critical').then(r => r.json());
}
function loadSecondaryContent() {
return fetch('/api/content/secondary').then(r => r.json());
}
function prefetchNextPage() {
// Prefetch based on user behavior
const nextPageUrl = predictNextPage();
fetch(nextPageUrl, { mode: 'no-cors' });
}
Exercise:
- Create an app shell with header, content area, and footer
- Implement skeleton screens for loading states
- Add service worker to cache the app shell
- Implement client-side routing that loads only content (not shell)
- Add progressive loading for critical and secondary content
- Test offline functionality - shell should work without network
- Measure performance: app shell should load in <1 second
Tip: Keep your app shell as small as possible. The smaller it is, the faster it loads. Move non-critical styles and scripts out of the shell and load them progressively.
Warning: Don't confuse app shell with single-page application (SPA). While they work well together, app shell is specifically about the caching architecture. You can implement app shell with traditional multi-page apps too.