Progressive Web Apps are often discussed alongside React, Angular, or Next.js, as if you need a framework to build one. You do not. A PWA is just a regular website that meets three criteria: it is served over HTTPS, it has a web app manifest, and it registers a service worker. None of these require a build tool or framework.

If you have a static site built with vanilla HTML, CSS, and JavaScript, you can turn it into an installable, offline-capable PWA in under an hour.

Step 1: The Web App Manifest

The manifest is a JSON file that tells the browser how your app should behave when installed. Create a file called manifest.json in your site root:

{
    "name": "My Developer Tool",
    "short_name": "DevTool",
    "description": "A free online developer utility",
    "start_url": "/",
    "display": "standalone",
    "background_color": "#f5f7fa",
    "theme_color": "#2c3e50",
    "icons": [
        {
            "src": "/icon-192.png",
            "sizes": "192x192",
            "type": "image/png"
        },
        {
            "src": "/icon-512.png",
            "sizes": "512x512",
            "type": "image/png"
        }
    ]
}

Then link it in your HTML <head>:

<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2c3e50">

The display: standalone setting makes the app look like a native app when installed -- no browser chrome, no address bar. The icons are what appear on the home screen. You need at least 192x192 and 512x512 PNG icons.

Step 2: The Service Worker

The service worker is where the real power is. It is a JavaScript file that runs in the background and can intercept network requests, cache responses, and serve content when the user is offline.

Create sw.js in your site root:

var CACHE_NAME = "v1";
var ASSETS = [
    "/",
    "/index.html",
    "/css/styles.css",
    "/js/scripts.js",
    "/favicon.ico"
];

// Install: cache core assets
self.addEventListener("install", function(event) {
    event.waitUntil(
        caches.open(CACHE_NAME).then(function(cache) {
            return cache.addAll(ASSETS);
        })
    );
});

// Activate: clean old caches
self.addEventListener("activate", function(event) {
    event.waitUntil(
        caches.keys().then(function(keys) {
            return Promise.all(
                keys.filter(function(key) {
                    return key !== CACHE_NAME;
                }).map(function(key) {
                    return caches.delete(key);
                })
            );
        })
    );
});

// Fetch: serve from cache, fall back to network
self.addEventListener("fetch", function(event) {
    event.respondWith(
        caches.match(event.request).then(function(response) {
            return response || fetch(event.request);
        })
    );
});

Register it from your main page:

if ("serviceWorker" in navigator) {
    navigator.serviceWorker.register("/sw.js");
}

Step 3: Choose a Caching Strategy

The example above uses a "cache-first" strategy: serve from cache if available, otherwise fetch from the network. This is ideal for static assets that rarely change. But there are other strategies worth knowing:

For a developer tool site, stale-while-revalidate is usually the right choice:

self.addEventListener("fetch", function(event) {
    event.respondWith(
        caches.open(CACHE_NAME).then(function(cache) {
            return cache.match(event.request).then(function(cached) {
                var fetched = fetch(event.request).then(function(response) {
                    cache.put(event.request, response.clone());
                    return response;
                });
                return cached || fetched;
            });
        })
    );
});

Cache Versioning

When you update your site, you need users to get the new version. The simplest approach: bump the CACHE_NAME version string (e.g., "v1" to "v2"). The activate event will clean up old caches, and the install event will cache the new assets.

This is manual and easy to forget. A more robust approach is to include a hash of your assets in the cache name, but for a hand-authored static site without a build tool, the manual version bump is pragmatic enough.

What You Get

With these three pieces in place (manifest, service worker, HTTPS), your vanilla site gains:

Common Pitfalls

Service worker scope. A service worker at /tools/sw.js can only control pages under /tools/. Place it at the root to control your entire site.

Caching CDN resources. If you load jQuery or Bootstrap from a CDN, the service worker can cache those responses too, but be aware that CORS and opaque responses can cause issues. Self-hosting critical assets avoids this entirely.

The update cycle. Browsers check for service worker updates roughly every 24 hours. During development, enable "Update on reload" in Chrome DevTools > Application > Service Workers to avoid stale cache headaches.

You do not need Workbox, you do not need a framework, and you do not need a build step. A vanilla static site with a manifest and a 40-line service worker is a fully functional PWA. Sometimes the simplest approach is also the most durable.