Progressive Web Apps (PWA)

Web App Shell Architecture

18 min Lesson 15 of 30

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

  1. Instant Loading: Shell loads from cache immediately
  2. Offline Support: Users can navigate even without network
  3. Native-like Performance: Feels like a native app
  4. Reduced Bandwidth: Shell cached once, content updates separately
  5. 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>&copy; 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:
  1. Create an app shell with header, content area, and footer
  2. Implement skeleton screens for loading states
  3. Add service worker to cache the app shell
  4. Implement client-side routing that loads only content (not shell)
  5. Add progressive loading for critical and secondary content
  6. Test offline functionality - shell should work without network
  7. 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.