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:

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:

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:

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.