My portfolio is built with vanilla HTML, CSS, and JavaScript — no build step, no framework. That makes it fast, but it also means I had to wire up Progressive Web App (PWA) features by hand. The payoff is real: the site is installable on a phone home screen, opens instantly on repeat visits, and still loads core pages with no network. Here is exactly how the pieces fit together.
What Makes a Site a PWA
Three ingredients turn an ordinary website into an installable app:
- A web app manifest — JSON metadata describing the app's name, icons, and colors.
- A Service Worker — a background script that intercepts network requests and serves cached responses.
- HTTPS — required for Service Workers (localhost is exempt for development).
Get all three right and browsers show an "Install" prompt, and the site keeps working when the connection drops.
The Web App Manifest
The manifest lives at manifest.json and is referenced from the <head> with <link rel="manifest" href="manifest.json">. A minimal but complete version looks like this:
{
"name": "Rashid — Developer Portfolio",
"short_name": "Rashid",
"start_url": "/",
"display": "standalone",
"background_color": "#050507",
"theme_color": "#6366f1",
"icons": [
{ "src": "/images/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/images/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
The two icon sizes matter: 192×192 for the home-screen icon and 512×512 for the splash screen. display: standalone is what hides the browser chrome so it feels like a native app.
Registering the Service Worker
Registration is one small block in the main script. I guard it behind a feature check so older browsers simply ignore it:
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then(reg => console.log('SW registered:', reg.scope))
.catch(err => console.warn('SW failed:', err));
});
}
Registering on the load event keeps the Service Worker from competing with the critical resources the page needs to render first.
The Cache Lifecycle
A Service Worker has three lifecycle events that matter: install, activate, and fetch. During install I pre-cache an "app shell" — the handful of files needed to render any page:
const CACHE = 'Rashid-v11';
const SHELL = [
'/', '/index.html', '/offline.html',
'/css/style.css', '/src/js/main.js',
'/images/logo.webp'
];
self.addEventListener('install', e => {
e.waitUntil(
caches.open(CACHE).then(c => c.addAll(SHELL))
);
self.skipWaiting();
});
The cache name carries a version (Rashid-v11). Bumping that number is how I force a clean update — the old cache is deleted on activate:
self.addEventListener('activate', e => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys.filter(k => k !== CACHE)
.map(k => caches.delete(k))
)
)
);
self.clients.claim();
});
Choosing a Fetch Strategy
The fetch handler is where you decide what to serve. There is no single "right" strategy — it depends on the resource:
- Cache-first — best for static assets (CSS, fonts, logos) that rarely change. Fast, but can serve stale files until the cache version bumps.
- Network-first — best for HTML and API data you want fresh. Falls back to cache when offline.
- Stale-while-revalidate — serve the cached copy instantly, then update the cache in the background for next time.
I use network-first for navigation requests so visitors always get the latest content, with a graceful offline page as the fallback:
self.addEventListener('fetch', e => {
if (e.request.mode === 'navigate') {
e.respondWith(
fetch(e.request).catch(() =>
caches.match('/offline.html')
)
);
return;
}
// cache-first for everything else
e.respondWith(
caches.match(e.request).then(hit =>
hit || fetch(e.request)
)
);
});
The Offline Fallback Page
Every PWA should ship an offline.html — a tiny self-contained page (no external CSS, no fonts) that explains the user is offline and offers a retry button. Keep it under a few kilobytes so it caches instantly and renders even on the worst connection.
Testing and Debugging
Chrome DevTools has an Application tab that became my best friend here:
- Service Workers panel — see the active worker, force an update, or toggle "Offline" to simulate no network.
- Cache Storage — inspect exactly what got cached under each version key.
- Manifest — Chrome validates your manifest and warns about missing icons or fields.
- Lighthouse — the PWA audit catches the common mistakes (no
theme_color, no 512px icon, not served over HTTPS).
The Update Trap
The single most confusing thing about Service Workers is caching the Service Worker itself. A new sw.js only takes over after every tab is closed unless you call skipWaiting() and clients.claim(). I learned this the hard way after shipping a fix that "didn't deploy" — it had deployed, the old worker just kept serving stale files. Always bump the cache version with every meaningful release.
Was It Worth It?
For a portfolio, absolutely. Repeat loads feel instant because the shell comes straight from cache, the site survives flaky mobile connections, and visitors can pin it to their home screen. None of it required a framework — just a manifest, one background script, and a clear caching strategy. If you run a static site, you can add the same offline-first behavior in an afternoon.
You can see this in action across the whole site — try loading a page, going offline, and navigating around.