PWA Mini Project — Build a Complete Offline-First PWA from Scratch
Build a complete offline-first PWA from scratch — service worker, manifest, Caching strategies, push notifications, and app store publishing — applying all concepts learned in this tutorial series.
What You'll Learn
By the end of this project, you will have built a production-ready PWA that works offline, can be installed, sends push notifications, and is ready for app store publishing.
Why It Matters
Theory without practice is forgotten. This project consolidates every PWA concept into a single, working application that you can deploy, test, and extend. Completing it demonstrates mastery of PWA development.
Project Overview
PWA Mini Project Architecture
┌──────────────────────────────────────────────────────────────┐
│ Offline Recipe PWA │
├──────────────────────────────────────────────────────────────┤
│ Frontend: HTML + CSS + Vanilla JS │
│ Backend: Node.js + Express (or mock API) │
│ Service Worker: Workbox (precache + runtime caching) │
│ Storage: Cache API + IndexedDB │
│ Push: VAPID + web-push │
│ Install: Manifest + beforeinstallprompt │
│ Sync: Background Sync (form submissions) │
└──────────────────────────────────────────────────────────────┘
Step 1: Project Structure
recipe-pwa/
index.html
offline.html
manifest.json
sw.js (or use Workbox)
styles/
main.css
scripts/
app.js
router.js
idb.js
images/
icon-192.png
icon-512.png
badge.png
server/
server.js
push.js
.well-known/
assetlinks.json
Step 2: App Shell HTML
Create the app shell with offline support:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#3367D6">
<meta name="apple-mobile-web-app-capable" content="yes">
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/images/icon-192.png">
<link rel="stylesheet" href="/styles/main.css">
<title>Recipe PWA</title>
</head>
<body>
<header class="app-header">
<a href="/" class="logo">Recipe PWA</a>
<nav>
<a href="/" data-route="home">Home</a>
<a href="/recipes" data-route="recipes">Recipes</a>
<a href="/favorites" data-route="favorites">Favorites</a>
</nav>
<button id="install-button" style="display:none">Install</button>
</header>
<main id="content">
<div class="loading">Loading recipes...</div>
</main>
<footer>
<p>Built by DodaTech — 2026</p>
</footer>
<script src="/scripts/app.js"></script>
</body>
</html>
Step 3: Service Worker with Workbox
// sw.js (using Workbox via CDN for simplicity)
importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.5.4/workbox-sw.js');
workbox.setConfig({ debug: false });
// Precache app shell
workbox.precaching.precacheAndRoute([
{ url: '/', revision: '1' },
{ url: '/offline.html', revision: '1' },
{ url: '/styles/main.css', revision: '1' },
{ url: '/scripts/app.js', revision: '1' }
]);
// Cache Google Fonts
workbox.routing.registerRoute(
/^https:\/\/fonts\.(googleapis|gstatic)\.com/,
new workbox.strategies.CacheFirst({
cacheName: 'google-fonts',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxAgeSeconds: 60 * 60 * 24 * 365
})
]
})
);
// Cache images
workbox.routing.registerRoute(
/\.(?:png|gif|jpg|jpeg|webp|svg)$/,
new workbox.strategies.CacheFirst({
cacheName: 'images',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 60 * 60 * 24 * 30
})
]
})
);
// Cache API responses
workbox.routing.registerRoute(
/\/api\//,
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'api-cache',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 60 * 60
})
]
})
);
// Navigation: network first with offline fallback
workbox.routing.registerRoute(
({ request }) => request.mode === 'navigate',
new workbox.strategies.NetworkFirst({
cacheName: 'pages',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: 20,
maxAgeSeconds: 60 * 60 * 24
})
]
})
);
Step 4: IndexedDB for Favorites
// scripts/idb.js
const DB_NAME = 'RecipePWA';
const DB_VERSION = 1;
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = event.target.result;
db.createObjectStore('favorites', { keyPath: 'id' });
db.createObjectStore('offline-recipes', { keyPath: 'id' });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function addFavorite(recipe) {
const db = await openDB();
const tx = db.transaction('favorites', 'readwrite');
tx.objectStore('favorites').put(recipe);
await new Promise(r => tx.oncomplete = r);
}
async function getFavorites() {
const db = await openDB();
const tx = db.transaction('favorites', 'readonly');
const store = tx.objectStore('favorites');
return new Promise(r => {
const request = store.getAll();
request.onsuccess = () => r(request.result);
});
}
Step 5: Push Notifications
// scripts/app.js — Push subscription
async function setupPush() {
if (!('PushManager' in window)) return;
const registration = await navigator.serviceWorker.ready;
// Subscribe
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array('YOUR_VAPID_PUBLIC_KEY')
});
// Send subscription to your server
await fetch('/api/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
headers: { 'Content-Type': 'application/json' }
});
}
// server/push.js
const webpush = require('web-push');
webpush.setVapidDetails(
'mailto:admin@example.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
async function sendRecipeNotification(subscriber, recipe) {
const payload = {
title: 'New Recipe Available',
body: recipe.title,
icon: '/images/icon-192.png',
badge: '/images/badge.png',
data: { url: `/recipes/${recipe.id}` }
};
try {
await webpush.sendNotification(subscriber, JSON.stringify(payload));
} catch (error) {
if (error.statusCode === 410) {
// Subscription expired, remove from DB
await removeSubscriber(subscriber.endpoint);
}
}
}
Step 6: Install Prompt
// scripts/app.js — Install prompt
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault();
deferredPrompt = event;
document.getElementById('install-button').style.display = 'block';
});
document.getElementById('install-button').addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const result = await deferredPrompt.userChoice;
console.log('Install result:', result.outcome);
deferredPrompt = null;
document.getElementById('install-button').style.display = 'none';
});
window.addEventListener('appinstalled', () => {
console.log('PWA installed successfully');
document.getElementById('install-button').style.display = 'none';
});
Step 7: Service Worker Registration
// scripts/app.js — Register service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('SW registered:', registration.scope);
} catch (error) {
console.log('SW registration failed:', error);
}
});
}
Step 8: Testing
Run the PWA through comprehensive tests:
const tests = {
offline: async () => {
// Go offline in DevTools
// Verify app loads without network
const response = await fetch('/');
console.log('Offline test:', response.ok ? 'PASS' : 'FAIL');
},
install: () => {
console.log('Manifest valid:',
!!document.querySelector('link[rel="manifest"]'));
console.log('Display mode:',
window.matchMedia('(display-mode: standalone)').matches);
},
caching: async () => {
const cacheNames = await caches.keys();
console.log('Caches:', cacheNames);
},
push: () => {
console.log('Push permission:', Notification.permission);
}
};
Common Mistakes
- Skipping the offline fallback page. Without offline.html, users see browser error pages. Always include and pre-cache an offline fallback.
- Not testing on real mobile devices. Emulators miss touch interaction issues, viewport problems, and performance characteristics.
- Ignoring iOS limitations. Test on Safari. Push notifications and Background Sync do not work on iOS.
- Forgetting the apple-touch-icon. iOS ignores manifest icons. Add apple-touch-icon link tag for iOS home screen installation.
- Not handling service worker update flow. Users get stuck on old versions. Implement update notifications and skipWaiting().
Practice Questions
- What are the minimum components required for a functional PWA?
- How does IndexedDB complement Cache Storage in a PWA?
- Why should you use Workbox instead of writing a service worker manually?
- What testing should you perform before deploying a PWA to production?
- How do you handle the service worker update lifecycle in a production PWA?
Challenge: Extend the Recipe PWA with: a "Save for Offline" button that stores recipes in IndexedDB, a "Sync Later" queue for user ratings submitted offline, and a periodic sync that refreshes the recipe list every 24 hours. Deploy to Netlify or Vercel and run a Lighthouse audit.
FAQ
Mini Project
Build the complete Recipe PWA described in this project. Register the service worker, pre-cache the app shell, add IndexedDB for favorites, implement push notifications, set up the install prompt, and create an offline fallback page. Deploy and run a Lighthouse audit aiming for 100 on PWA category.
What's Next
Congratulations on completing the PWA tutorial series. Next, explore related topics: Single-Page Applications for dynamic client-rendered apps, or Server-Side Rendering for SEO-friendly JavaScript applications.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro