How to Build a Progressive Web App from Scratch: A Step-by-Step Tutorial with Service Workers

One of the most popular services we offer is ongoing website maintenance because most clients we work with become return clients.

How to Build a Progressive Web App from Scratch: A Step-by-Step Tutorial with Service Workers

How to Build a Progressive Web App from Scratch

If you have been searching how to build a progressive web app, most tutorials either drop you into a heavy framework or skip the parts that actually make a PWA feel native. In this guide, we will do the opposite. We will start with an empty folder and ship a working Progressive Web App: a simple notes app that saves locally, works offline, and can be installed on Android, iOS, Windows and macOS.

By the end of this tutorial, you will understand the three pillars of every PWA:

  • The Web App Manifest (metadata + installability)
  • The Service Worker (offline + caching)
  • The App Shell (fast, resilient UI)

No React, no Vite, no build tools. Just HTML, CSS and vanilla JavaScript, so you can transfer the knowledge to any stack later.

progressive web app code

What You Need Before Starting

  • A code editor (VS Code recommended)
  • A modern browser (Chrome, Edge or Firefox)
  • A local HTTPS-capable server (we will use npx serve)
  • Basic knowledge of HTML, CSS and JavaScript

Important: Service workers only run over HTTPS or on localhost. Opening the file directly with file:// will not work. Get started developing a PWA tackles the same question from another angle.

Step 1: Create the Project Structure

Create an empty folder called pwa-notes and add these files:

File Purpose
index.html App shell and UI
styles.css Styling
app.js Notes logic + SW registration
sw.js Service worker
manifest.webmanifest PWA metadata
/icons App icons (192×192 and 512×512)

Step 2: Build the App Shell (index.html)

The app shell is the minimum HTML, CSS and JavaScript needed to power the UI. It should load instantly, even offline.

<!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="#4f46e5">
  <title>PWA Notes</title>
  <link rel="manifest" href="manifest.webmanifest">
  <link rel="stylesheet" href="styles.css">
  <link rel="apple-touch-icon" href="icons/icon-192.png">
</head>
<body>
  <header><h1>My Notes</h1></header>
  <main>
    <textarea id="note" placeholder="Write something..."></textarea>
    <button id="save">Save note</button>
    <ul id="list"></ul>
  </main>
  <script src="app.js"></script>
</body>
</html>
progressive web app code

Step 3: Style the App (styles.css)

* { box-sizing: border-box; font-family: system-ui, sans-serif; }
body { margin: 0; background: #f9fafb; color: #111; }
header { background: #4f46e5; color: white; padding: 1rem; }
main { padding: 1rem; max-width: 600px; margin: auto; }
textarea { width: 100%; height: 100px; padding: .5rem; }
button { background: #4f46e5; color: white; border: 0; padding: .6rem 1rem; border-radius: 6px; cursor: pointer; }
ul { list-style: none; padding: 0; }
li { background: white; margin-top: .5rem; padding: .6rem; border-radius: 6px; box-shadow: 0 1px 2px rgba(0,0,0,.05); }

Step 4: Create the Web App Manifest

The manifest tells the browser your site is installable. Save the following as manifest.webmanifest:

{
  "name": "PWA Notes",
  "short_name": "Notes",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4f46e5",
  "description": "A tiny offline-first notes app.",
  "icons": [
    { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
  ]
}

Tip: Generate icons quickly with a tool like maskable.app or export a square PNG from Figma. You need at least a 192×192 and a 512×512 icon.

Step 5: Write the Notes Logic and Register the Service Worker

In app.js, we store notes in localStorage and register the service worker after the page loads. There is a detailed walkthrough elsewhere.

const noteEl = document.getElementById('note');
const listEl = document.getElementById('list');
const saveBtn = document.getElementById('save');

function getNotes() {
  return JSON.parse(localStorage.getItem('notes') || '[]');
}
function render() {
  listEl.innerHTML = getNotes().map(n => `<li>${n}</li>`).join('');
}
saveBtn.addEventListener('click', () => {
  const value = noteEl.value.trim();
  if (!value) return;
  const notes = getNotes();
  notes.unshift(value);
  localStorage.setItem('notes', JSON.stringify(notes));
  noteEl.value = '';
  render();
});
render();

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js')
      .then(reg => console.log('SW registered', reg.scope))
      .catch(err => console.error('SW error', err));
  });
}
progressive web app code

Step 6: Write the Service Worker (sw.js)

The service worker is a background script that intercepts network requests. This is what makes the app work offline.

const CACHE = 'notes-v1';
const ASSETS = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/manifest.webmanifest',
  '/icons/icon-192.png',
  '/icons/icon-512.png'
];

self.addEventListener('install', event => {
  event.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)));
  self.skipWaiting();
});

self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(keys =>
      Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
    )
  );
  self.clients.claim();
});

self.addEventListener('fetch', event => {
  const req = event.request;
  if (req.method !== 'GET') return;
  event.respondWith(
    caches.match(req).then(cached => {
      const network = fetch(req).then(res => {
        const copy = res.clone();
        caches.open(CACHE).then(c => c.put(req, copy));
        return res;
      }).catch(() => cached);
      return cached || network;
    })
  );
});

Understanding the Caching Strategy

We used a stale-while-revalidate pattern:

  1. The service worker returns the cached response instantly for speed.
  2. In parallel, it fetches a fresh copy from the network.
  3. The new response replaces the cached one for the next visit.

Other common strategies you can use in sw.js:

Strategy Best for
Cache first Static assets (CSS, fonts, icons)
Network first API calls, dynamic content
Stale-while-revalidate HTML, general resources
Cache only Pre-cached shell files

Step 7: Run and Test the PWA

From the project folder, run:

npx serve .

Open the URL shown in your terminal (usually http://localhost:3000). Then:

  1. Open Chrome DevTools then go to the Application tab.
  2. Check Manifest: your name, icons and theme should appear.
  3. Check Service Workers: it should say activated and running.
  4. In the Network tab, switch to Offline and reload. The app should still work.
  5. Look for the install icon in the browser address bar and click it.

Step 8: Audit With Lighthouse

Still in DevTools, open the Lighthouse panel, choose Progressive Web App and run the audit. You should hit green on:

  • Installable manifest
  • Service worker registered
  • Works offline (fallback)
  • Themed address bar
  • Correct viewport

If something fails, Lighthouse tells you exactly which field is missing. Fix it and re-run.

progressive web app code

Going Further: Improvements to Ship Next

Once your first PWA is live, here are upgrades that make a real difference in 2026:

  • IndexedDB instead of localStorage for larger, structured data.
  • Background Sync to queue actions while offline.
  • Web Push notifications to bring users back.
  • Share Target API so other apps can share content into yours.
  • Workbox to generate advanced service workers without writing them by hand.

Common Mistakes to Avoid

  • Registering the service worker before the page loads (blocks rendering).
  • Forgetting to bump the cache version when you deploy changes.
  • Caching POST requests (they will break).
  • Missing 192px or 512px icons (installability fails silently).
  • Serving over HTTP in production. Always use HTTPS.

FAQ

Do I need a framework like React or Vue to build a PWA?

No. A PWA is defined by the manifest and service worker, not by the framework. You can build one with plain HTML like we did, or on top of React, Vue, Svelte, Angular or even WordPress.

Is a Progressive Web App still relevant in 2026?

Yes. PWAs are now supported on iOS, Android, Windows, macOS and ChromeOS. Install prompts, push notifications on iOS, and richer device APIs have made PWAs a serious alternative to native apps for most business use cases.

How do I update a PWA after users have installed it?

Change the cache name (for example from notes-v1 to notes-v2) whenever you deploy. The new service worker will install in the background, clear the old cache on activation, and take over on the next reload.

Can a PWA work fully offline?

Yes, if you pre-cache the shell files and store user data locally with IndexedDB or localStorage. Just remember: any request that hits the network must have a fallback in the fetch handler.

How do I publish a PWA to app stores?

Use PWABuilder to wrap your PWA into packages for Google Play, the Microsoft Store and (via Bubblewrap or similar) even iOS. You keep a single codebase and distribute everywhere.

Wrapping Up

You now know how to build a progressive web app from scratch: an app shell, a manifest, a service worker with a smart caching strategy, and offline support. The notes app we built is small on purpose, but the exact same architecture powers massive PWAs used by millions of people. Take this template, plug it into your next project, and ship something users can install today.

Subscription Form

Contact Details

Quick Links

Copyright © 2022 Pixel Seed. All Rights Reserved.