The starting point
We start from a small bakery site: index.html, menu.html, about.html, a stylesheet and a logo. No build step, no framework. The goal: visitors can install it, it opens instantly, and it keeps working without a network.
acme-bakery/
index.html
menu.html
about.html
style.css
logo.svgStep 1: the manifest
Create manifest.webmanifest next to index.html. The id gives the app a stable identity, start_url is the page it opens, scope the pages that belong to it, and display removes the browser interface:
{
"id": "./",
"name": "Acme Bakery",
"short_name": "Acme",
"start_url": "./",
"scope": "./",
"display": "standalone",
"theme_color": "#e8590c",
"background_color": "#ffffff",
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}Export the icons from your logo. The maskable one needs extra padding: Android crops it to a circle or squircle, and only the center 80% is guaranteed to show.
Step 2: a service worker that caches the site
The worker runs between your pages and the network. On install it downloads the files the app needs; on fetch it decides where each response comes from. Bump VERSION whenever a file changes, or the browser keeps the old cache:
const VERSION = 'v1';
const FILES = ['./', 'menu.html', 'about.html', 'style.css', 'offline.html', 'icons/icon-192.png'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(VERSION).then((cache) => cache.addAll(FILES)));
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))),
);
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
if (event.request.mode === 'navigate') {
// Pages: network first, then the cache, then the offline page.
event.respondWith(
fetch(event.request).catch(async () => (await caches.match(event.request)) || caches.match('offline.html')),
);
return;
}
// Everything else: cache first.
event.respondWith(caches.match(event.request).then((hit) => hit || fetch(event.request)));
});This works, but it has three weaknesses: a slow network makes pages hang instead of falling back, every deploy downloads every file again, and you must remember to bump the version.
Step 3: link and register
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#e8590c">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<script>
if ('serviceWorker' in navigator) {
addEventListener('load', () => navigator.serviceWorker.register('sw.js'));
}
</script>Repeat that block in every page. The worker must sit at the root, because it only controls the folder it is served from and the folders below.
Step 4: updates
When sw.js changes, the browser installs the new worker but keeps the old one in charge until every tab of the site is closed. To apply an update sooner, tell the page and let the visitor choose:
const reg = await navigator.serviceWorker.register('sw.js');
reg.addEventListener('updatefound', () => {
const next = reg.installing;
next.addEventListener('statechange', () => {
if (next.state === 'installed' && navigator.serviceWorker.controller) {
if (confirm('A new version is available. Refresh?')) next.postMessage('skip');
}
});
});
navigator.serviceWorker.addEventListener('controllerchange', () => location.reload());
// sw.js: self.addEventListener('message', (e) => e.data === 'skip' && self.skipWaiting());Step 6: test and deploy
Serve the folder from localhost, open it in Chrome or Edge and look at the Application panel: the manifest should parse with no warnings, the worker should be activated and the cache should hold your files. Tick Offline in the Network panel and reload; the pages you cached still open, others show the offline page. Then deploy to any HTTPS host and install it from the address bar.
What usually goes wrong
Most broken progressive web apps fail in the same few ways. The worker is served from a subfolder such as /js/sw.js, so it cannot control the pages above it. The cache list names a file that no longer exists after a redesign, so cache.addAll rejects and the new worker never installs, silently. The version constant is not bumped, so visitors keep yesterday's styles for days. Or the manifest is linked with an absolute path on a site that lives in a subfolder. Each of these is easy to miss in development, where you reload with the cache disabled, and easy to catch with an audit before you deploy.
Step 7: the same thing, automated
Everything above, plus the parts that are tedious by hand, comes out of one command:
cd acme-bakery
npx installkite init --yes
npx installkite doctorThe generated worker fixes the three weaknesses of the hand written one. Pages race the network against a three second timeout and fall back to the cache. Each precached file carries a content hash, so a deploy downloads only the files that changed. And installkite build rewrites the precache list after each site build, so you never bump a version by hand. The runtime adds the install button, the iPhone instructions and the Refresh to update toast.
Questions
What are the minimum requirements for a PWA?
A web app manifest with a name, icons of 192 and 512 pixels, a start URL and a standalone display mode, a service worker with a fetch handler, and HTTPS.
Do I need a framework to build a PWA?
No. A progressive web app is a set of files that any website can ship: a manifest, icons and a service worker. This tutorial uses plain HTML.
How do I test a PWA locally?
Serve the folder from localhost, which browsers treat as secure, for example with npx serve or python -m http.server. The Application panel in Chrome or Edge developer tools shows the manifest, the worker and the caches, and has an Offline switch.
Why does my service worker not update?
Browsers compare the bytes of sw.js. If the file does not change, nothing updates. Put a version or the file hashes in it, which installkite build does after each site build, and serve sw.js with a short cache lifetime.