You might have seen browsers allow you to install a website like an app. Building a native application takes significant effort, and not many common applications support it. Google Calendar and Gemini are examples. To use them like a native app, I just install them via Chrome as a web application.
The advantage of installing a web app is that it runs in its own window, appears in your taskbar or dock, loads faster on repeat visits, and can work offline. You get most of what a native app offers without going through an app store. That's what a Progressive Web App (PWA) enables. And the Service Worker is the piece of technology that makes most of it possible.
What is a Progressive Web App (PWA)?
A progressive web app (PWA) is an app that's built using web platform technologies, but that provides a user experience like that of a platform-specific app.
Ref: Progressive web apps
A PWA meets a set of criteria to unlock native-like capabilities. Not a framework, not a library, just a set of standards your app can progressively adopt. The three pillars are:
- Reliable: loads instantly, even on flaky networks or offline.
- Fast: responds quickly to user interactions.
- Engaging: installable on the home screen, can send push notifications.
To be installable, a PWA needs at minimum: a manifest.json , a registered Service Worker, and HTTPS.
The Web App Manifest
The manifest is a JSON file that tells the browser how your app should behave when installed: its name, icons, theme color, and launch behavior.
// language: javascript
{
"name": "My Blog",
"short_name": "Blog",
"start_url": "/",
"display": "standalone",
"theme_color": "#0f0f0f",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}The display: standalone key is important. It removes the browser chrome (address bar, back button) when launched from the home screen, making it feel like a real app.
Link it in your HTML:
Link it in your HTML:
// language: html <link rel="manifest" href="/manifest.json" /> <meta name="theme-color" content="#0f0f0f" />
What is a Service Worker?
A Service Worker is a JavaScript file that runs in a separate thread, with no DOM access, no blocking the UI. It acts as a programmable proxy between your app and the network.
// language: text
Your App ── fetch() ──> Service Worker ── maybe ──> Network
│
└──── or ──> CacheEvery network request passes through the Service Worker first. You decide: hit the network, serve from cache, or both.
Registering a Service Worker
// language: javascript
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/sw.js')
.then(reg => console.log('SW registered', reg.scope))
.catch(err => console.error('SW failed', err));
}The file path matters. A
- Putting SW in
/assets/sw.js : scope becomes/assets/ (almost useless) - Expecting
/blog/sw.js to control the entire site - Forgetting leading
/ : relative path issues
Every time you load a page that calls the above registering script, the browser:
- Fetches
/sw.js - Compares it with the cached version
- If byte-different, it will trigger an update
Service Worker Lifecycle
Install
Fires the first time the browser sees your SW, or when the SW file changes. Pre-cache your shell here.
// language: javascript
const CACHE = 'blog-v1';
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE).then(cache => cache.addAll(['/', '/offline.html']))
);
self.skipWaiting(); // activate immediately
});Activate
Fires after install. Clean up old caches from previous deploys here.
// language: javascript
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
)
);
self.clients.claim();
});Fetch
Fires on every network request. Your caching strategy lives here.
// language: javascript
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cached => cached || fetch(event.request))
);
});Caching Strategies:
- Cache First: Serve cache, fall back to network
- Network First: Try network, fall back to cache
- Stale While Revalidate: Return cache immediately, update in background
- Network Only: Always hit the network
Cache clears when:
- You bump the cache name: changing
blog-v1 toblog-v2 means the activate event deletes the old one on the next deploy. This is the standard pattern. - The user clears browser data: DevTools or browser settings.
- Browser storage pressure: if the device is critically low on disk, the browser may evict your cache. No guarantee of permanence.
- You delete it explicitly:
caches.delete('blog-v1') orcache.delete(specificRequest)
Conclusion
You don't have to go all in. Start with a manifest and a minimal Service Worker that shows an offline page. Then layer in smarter caching as you understand your app's traffic patterns.