Skip to content

PWA Mini Project — Build a Complete Offline-First PWA from Scratch

DodaTech Updated 2026-06-28 6 min read

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

  1. Skipping the offline fallback page. Without offline.html, users see browser error pages. Always include and pre-cache an offline fallback.
  2. Not testing on real mobile devices. Emulators miss touch interaction issues, viewport problems, and performance characteristics.
  3. Ignoring iOS limitations. Test on Safari. Push notifications and Background Sync do not work on iOS.
  4. Forgetting the apple-touch-icon. iOS ignores manifest icons. Add apple-touch-icon link tag for iOS home screen installation.
  5. Not handling service worker update flow. Users get stuck on old versions. Implement update notifications and skipWaiting().

Practice Questions

  1. What are the minimum components required for a functional PWA?
  2. How does IndexedDB complement Cache Storage in a PWA?
  3. Why should you use Workbox instead of writing a service worker manually?
  4. What testing should you perform before deploying a PWA to production?
  5. 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

How long does it take to build a production PWA?

A basic PWA with app shell, caching, and manifest takes 1-2 weeks. Adding push notifications, Background Sync, and store publishing adds 1-2 more weeks.

Should I use a PWA framework or build from scratch?

For learning, build from scratch. For production, use Workbox for the service worker and a framework (React, Vue) for the UI. This project uses Vanilla JS for clarity.

How do I deploy a PWA?

Deploy the static files (HTML, CSS, JS, manifest, service worker) to any static hosting: Netlify, Vercel, GitHub Pages, or Firebase Hosting. Ensure HTTPS is enabled.

What is the minimum browser support for PWAs?

Chrome 67+, Firefox 60+, Safari 11.3+, Edge 79+. Service workers and the Cache API are supported in all modern browsers. Push notifications have partial support.

How do I migrate an existing website to a PWA?

Start by adding HTTPS, then create a manifest.json, then add a service worker with caching. Implement each feature incrementally. No rewrite is necessary.

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