I Turned a One-Page Classroom Tool Into an Installable PWA (No Framework, No Backend)
My name picker is a static multi-page site: hand-written HTML, one shared styles.css, one app.js, no build step, no bundler, no framework. The last thing I wanted to add was a Node toolchain.
So when teachers started asking two things — "can I get this on my classroom tablet's home screen?" and "what happens when the school wifi drops?" — I made it a Progressive Web App. It's a few files, zero dependencies, and it gives you an installable icon plus offline mode for a site that was never "an app."
The three pieces
A PWA is really just three things working together:
- A web app manifest so the browser can treat it like an app (name, icons, standalone display).
- A service worker so the shell and assets can be served from cache, including when you're offline.
- An install prompt so the user can actually put it on their home screen.
1. The manifest — manifest.webmanifest
{
"name": "Random Student Picker",
"short_name": "Name Picker",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#6C63FF",
"icons": [
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
Link it in the <head>: <link rel="manifest" href="/manifest.webmanifest">. The maskable icon matters — it's the safe-zone version Android uses so your icon doesn't get cropped into a circle.
2. The service worker — cache-first, then revalidate
The design: serve the cached file instantly, and in the background fetch the network version to refresh the cache. This keeps the classroom experience instant even before the connection is confirmed, and it self-heals when a page changes.
const CACHE = 'rsp-v1';
const CORE = ['/', '/styles.css', '/app.js', '/manifest.webmanifest'];
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(CORE)));
});
self.addEventListener('fetch', (e) => {
if (e.request.method !== 'GET') return;
e.respondWith(
caches.open(CACHE).then(async (cache) => {
const cached = await cache.match(e.request);
const network = fetch(e.request).then((res) => {
if (res.ok) cache.put(e.request, res.clone());
return res;
}).catch(() => cached);
return cached || network; // cache-first, revalidate in background
})
);
});
The one rule I'd never break for a tool like this: navigation requests need an offline fallback, or a bad wifi moment throws a white screen in front of a class.
// inside the fetch handler, for navigations only:
if (e.request.mode === 'navigate') {
e.respondWith(
fetch(e.request).catch(() => caches.match('/'))
);
return;
}
3. The install banner — beforeinstallprompt
Chrome fires beforeinstallprompt when the app meets the installability criteria (served over HTTPS, has a manifest, has a service worker). You can't call prompt() yourself — you can only show your own button when the user is ready:
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
document.getElementById('installBtn').hidden = false;
});
document.getElementById('installBtn').addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
await deferredPrompt.userChoice;
deferredPrompt = null;
document.getElementById('installBtn').hidden = true;
});
Why "no framework" actually helped
Because there's no build step, the service worker precaches a short, known list of files — CORE above is the whole site. With a bundled SPA you're precaching hashed chunks and juggling stale bundles. A hand-written static site makes the offline story trivial: the files don't change shape, so the cache is honest.
The tradeoff is that you wire it all by hand (the SW registration, the manifest link, the icon set, the apple-touch-icon for iOS). For a multi-page static site that's a few hours, not a toolchain migration.
The honest limits
- A PWA is not an APK. On iOS the "Add to Home Screen" flow still shows a little browser chrome.
- If you add real user data later, "cache-first" needs care so you don't serve stale state. (This tool stores the roster in
localStorage, not the cache, which sidesteps that.)
For a static tool that teachers open on a shared Chromebook with flaky wifi, it was exactly the right amount of complexity: installable, instant, offline-capable, and still just HTML/CSS/JS I can read top to bottom.
You can see the running result at randomstudentpicker.online — the install banner and offline mode are live on it.
Full disclosure: I built this tool. If you've made a framework-free PWA, I'm curious what you'd change about the cache strategy above.
Top comments (0)