0%
Reading Settings
Font Size
18px
Line Height
1.5
Letter Spacing
0.01em
Font Family
Table of contents
    blog cover

    PWA and Service Worker: Making Your Web App Feel Native

    Software Engineer
    Software Engineer
    Frontend
    Frontend
    published 2026-04-25 17:28:32 +0700 · 3 mins read
    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:
    // language: html
    <link rel="manifest" href="/manifest.json" />
    <meta name="theme-color" content="#0f0f0f" />

    You can find more configs here: Web application manifest

    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 ──> Cache

    Every 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 /sw.js at the root controls the entire origin. A /blog/sw.js only controls requests under /blog/ Common mistakes:
    • 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 to blog-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') or cache.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.

    Related blogs