Building a Progressive Web App

· 7 min read · Updated April 19, 2026 · intermediate
javascript pwa manifest service-worker offline installable

A Progressive Web App (PWA) is a web application that uses browser APIs to behave like a native app. Users can install it on their home screen, it works offline, and it can send push notifications. The “progressive” part means it works everywhere — and on capable browsers it adds more features.

Three things make a web app a PWA:

  • HTTPS — required for Service Workers and most modern APIs
  • A Web App Manifest — describes the app to the OS and browser
  • A Service Worker — handles offline functionality and background tasks

This guide covers all three. By the end you will have a working PWA that is installable, survives network failures, and feels like a native application.

The Web App Manifest

The manifest is a JSON file that tells the browser how to present your app when it is installed. Without it, the browser cannot offer to add your app to the home screen.

Create manifest.json in your public or root directory:

{
  "name": "Taskboard",
  "short_name": "Tasks",
  "description": "A simple offline-capable task manager",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#3b82f6",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ]
}

Add it to your HTML with a <link> tag in the <head>:

<link rel="manifest" href="/manifest.json">

Key manifest fields

name and short_namename appears in the install prompt, short_name appears on the home screen icon label. If short_name is longer than 12 characters the OS may truncate it, so keep it short.

icons — required for installability. You need at least one icon. A 192x192 and a 512x512 PNG are the standard minimum. The maskable purpose creates a safe zone so icons do not get cropped on masks or rounded corners.

display controls how the app looks when launched:

ValueResult
fullscreenNo browser chrome at all
standaloneNo URL bar, own window
minimal-uiMinimal navigation controls
browserNormal browser tab

Use standalone for a native app feel.

start_url is the page that loads when someone opens the installed app. Set it to / or a specific path. Adding UTM parameters here lets you track how many installs come from your marketing campaign:

"start_url": "/?utm_source=homescreen&utm_campaign=pwa"

scope defines which URLs the app covers. Pages outside the scope open in the browser instead of the app window. Keep it consistent — if your app lives at /app/, scope should be /app/.

Verifying the manifest

Chrome DevTools shows the Manifest panel under Application. If icons are missing or fields are invalid it flags them there. The Lighthouse PWA audit also checks for manifest correctness.

Registering a Service Worker

The Service Worker is a JavaScript file that runs in the background, separate from your page. It can intercept network requests, read and write to caches, and run code even when the page is closed.

Create a file called sw.js in your public directory:

const CACHE_NAME = 'taskboard-v1';
const ASSETS_TO_CACHE = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/icons/icon-192.png',
  '/icons/icon-512.png'
];

// Install: cache the shell assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(ASSETS_TO_CACHE);
    })
  );
  self.skipWaiting();
});

// Activate: clean up old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      );
    })
  );
  self.clients.claim();
});

Register it in your main JavaScript file:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then((registration) => {
      console.log('Service Worker registered:', registration.scope);
    })
    .catch((error) => {
      console.error('Service Worker registration failed:', error);
    });
}

The Service Worker lifecycle

Service Workers have three lifecycle events:

install fires once, when the worker first executes. Use it to pre-cache your app shell — the HTML, CSS, and JavaScript that make the app work offline. Calling self.skipWaiting() forces the new worker to activate immediately instead of waiting for all tabs to close.

activate fires when the old worker is replaced. Use it to delete old caches that are no longer needed.

fetch fires for every network request made from pages under the worker’s scope. This is where you implement caching strategies.

Caching strategies

The right strategy depends on the type of content.

Cache-first — good for static assets like CSS, JavaScript, and images. Check the cache first, fall back to the network only if the cache misses:

self.addEventListener('fetch', (event) => {
  if (event.request.destination === 'image') {
    event.respondWith(
      caches.match(event.request).then((cached) => {
        return cached || fetch(event.request).then((response) => {
          const clone = response.clone();
          caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
          return response;
        });
      })
    );
  }
});

Network-first — good for dynamic content that changes frequently. Try the network first, fall back to the cache if offline:

self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then((response) => {
          const clone = response.clone();
          caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
          return response;
        })
        .catch(() => caches.match(event.request))
    );
  }
});

Stale-while-revalidate — serve stale content immediately, update the cache in the background for next time. Best for content that changes but does not need to be perfectly fresh:

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.match(event.request).then((cached) => {
        const fetchPromise = fetch(event.request).then((response) => {
          cache.put(event.request, response.clone());
          return response;
        });
        return cached || fetchPromise;
      });
    })
  );
});

Making the app work offline

Once you have a Service Worker that caches your app shell, the app works offline. Users who visit your site once can open it again without a connection and see the cached content.

Open Chrome DevTools → Application → Service Workers to see which caches are active and test offline mode by checking “Offline” in the Network tab.

Making the App Installable

The browser fires a beforeinstallprompt event when your PWA meets the installability criteria. You can capture this event and show an “Install” button in your UI:

let deferredPrompt;

window.addEventListener('beforeinstallprompt', (event) => {
  event.preventDefault();
  deferredPrompt = event;

  const installButton = document.getElementById('install-button');
  installButton.style.display = 'block';

  installButton.addEventListener('click', async () => {
    if (!deferredPrompt) return;

    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;
    console.log('Install outcome:', outcome);

    deferredPrompt = null;
    document.getElementById('install-button').style.display = 'none';
  });
});

window.addEventListener('appinstalled', () => {
  deferredPrompt = null;
  console.log('App was installed');
});

deferredPrompt.prompt() shows the browser’s native install dialog. You cannot customize this dialog — its appearance depends on the OS and browser.

Push Notifications

Push notifications require a Service Worker and a server to send the push messages. The browser receives them through the Push API.

In your Service Worker, listen for the push event:

self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? { title: 'Taskboard', body: 'New notification' };

  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icons/icon-192.png',
      badge: '/icons/badge.png',
      data: data.url || '/'
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(clients.openWindow(event.notification.data));
});

To actually receive push messages from a server, you need a push subscription. This requires a backend — the browser sends an endpoint URL to your server, and your server uses it to send push messages via Web Push.

For a production PWA, services like Firebase Cloud Messaging (FCM) handle the server-side complexity and provide SDKs for Node.js, Go, Python, and other languages.

What Is Not Covered Here

Building a complete PWA also involves:

  • Background Sync — queueing actions when offline and sending them when connectivity returns
  • Periodic Background Sync — refreshing content in the background without user interaction
  • File handling — registering your app as a handler for specific file types
  • Shortcuts — right-click menu items on the home screen icon
  • App Badging — showing a number on the app icon

These are well-documented on MDN. Start with the three core features in this guide — manifest, Service Worker with offline caching, and installability — before adding more.

Common Mistakes

Serving the Service Worker from a different origin. The Service Worker must be served from the same origin as your app. A common error is putting it in a CDN or subdomain.

Caching API responses without versioning the cache name. When you deploy a new version of your app, change CACHE_NAME to 'taskboard-v2'. The old cache stays until the activate event deletes it.

Caching too much. Pre-caching everything including large images and API responses fills up the user’s storage quota. Cache only the app shell and use network-first for anything large or dynamic.

Missing icons. A manifest without valid icons will prevent installability on most browsers. Run Lighthouse’s PWA audit to catch missing or incorrectly sized icons.

See Also