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.

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>

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));
});
}

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:
- The service worker returns the cached response instantly for speed.
- In parallel, it fetches a fresh copy from the network.
- 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:
- Open Chrome DevTools then go to the Application tab.
- Check Manifest: your name, icons and theme should appear.
- Check Service Workers: it should say activated and running.
- In the Network tab, switch to Offline and reload. The app should still work.
- 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.

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.