Skip to content
C Codeloom
Web

Progressive Web Apps (PWA): What They Are and When To Build One

A practical introduction to PWAs: manifests, service workers, offline caching, install prompts, and when a PWA is the right choice versus a native app.

·6 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • What a PWA actually is, in concrete terms
  • How the web app manifest controls install behavior
  • How service workers enable offline support
  • Common caching strategies and their tradeoffs
  • When to ship a PWA instead of a native app

Prerequisites

  • Basic HTML and JavaScript
  • Familiarity with web app structure helps

A Progressive Web App is a website that behaves more like an installed app. It can be added to the home screen, opened in its own window, run offline, receive push notifications, and update in the background. None of that requires an app store, a native SDK, or a different codebase.

What makes a site a PWA

Three concrete pieces:

  • A web app manifest at /manifest.webmanifest.
  • A service worker registered from a top-level page.
  • HTTPS, except on localhost.

Add those to a regular site and the browser starts offering install and offline behavior.

The manifest

The manifest tells the OS how to present your app when installed.

{
  "name": "Codeloom",
  "short_name": "Codeloom",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "background_color": "#0b0b0c",
  "theme_color": "#0b0b0c",
  "icons": [
    { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icons/mask.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ]
}

Link it from your HTML:

<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0b0b0c">

display: standalone removes the browser chrome. scope defines which URLs the installed app handles. Provide a maskable icon for clean Android home-screen icons.

The service worker

A service worker is a script the browser runs in the background, separate from any page. It can intercept network requests, cache responses, and react to push events. It has no DOM and no synchronous storage.

Register it once:

if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/sw.js");
}

A minimal worker that caches the app shell:

const CACHE = "shell-v1";
const ASSETS = ["/", "/styles.css", "/app.js", "/offline.html"];

self.addEventListener("install", e => {
  e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)));
});

self.addEventListener("activate", e => {
  e.waitUntil(
    caches.keys().then(keys =>
      Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
    )
  );
});

self.addEventListener("fetch", e => {
  e.respondWith(
    caches.match(e.request).then(cached => cached || fetch(e.request))
  );
});

This handles the cache lifecycle: install populates, activate cleans up old versions, fetch answers from cache when possible.

Caching strategies

Pick a strategy per resource type.

  • Cache first: serve from cache, fall back to network. Best for hashed static assets.
  • Network first: try network, fall back to cache. Best for HTML and personalized data.
  • Stale while revalidate: serve cache immediately, fetch fresh in the background. Best for lists and feeds.
  • Network only: never cache. Best for analytics and auth.
  • Cache only: never fetch. Best for assets you know you pre-cached.

Stale-while-revalidate sketch:

self.addEventListener("fetch", e => {
  if (!e.request.url.includes("/api/feed")) return;
  e.respondWith((async () => {
    const cache = await caches.open("feed");
    const cached = await cache.match(e.request);
    const networkPromise = fetch(e.request).then(res => {
      cache.put(e.request, res.clone());
      return res;
    });
    return cached || networkPromise;
  })());
});

Tools like Workbox generate this code from a config. For most teams, that is the right call. Hand-rolled service workers tend to grow stale and buggy.

Updates and versioning

The browser checks the worker file for changes on navigation. A new worker installs in the background and activates on the next reload that has no controlled pages. To make updates feel snappy, prompt the user:

navigator.serviceWorker.addEventListener("controllerchange", () => {
  window.location.reload();
});

And in the worker:

self.addEventListener("message", e => {
  if (e.data === "SKIP_WAITING") self.skipWaiting();
});

The app shows a small banner when an update is ready. The user taps it, the page posts SKIP_WAITING, the new worker takes over, the page reloads with new code.

Install prompts

Browsers fire beforeinstallprompt when criteria are met: valid manifest, registered service worker, HTTPS, and some engagement signal. Capture and trigger it on a button:

let deferred;
window.addEventListener("beforeinstallprompt", e => {
  e.preventDefault();
  deferred = e;
  document.querySelector("#install").hidden = false;
});

document.querySelector("#install").addEventListener("click", async () => {
  if (!deferred) return;
  await deferred.prompt();
  deferred = null;
});

iOS does not fire this event. Users add to home screen through the Share menu. Provide instructions on that platform.

What PWAs do well

  • Repeat visits are instant. Cached shells beat any cold-start native app.
  • Offline reading, drafting, and queueing work even on bad networks.
  • Push notifications on Android, and now on iOS for installed PWAs.
  • One codebase, no store review, instant updates.
  • Linkable: every screen is a URL you can share.

What PWAs still struggle with

  • Deep OS integration. Background processing, advanced Bluetooth, contact pickers are still spotty.
  • iOS limits: smaller storage budgets, stricter background rules.
  • Discoverability through app stores.
  • Some hardware APIs are Chrome-only.

If you need a 3D engine running at 120 FPS or deep platform integration, native or a hybrid like React Native or Capacitor is the better choice. For most content, commerce, productivity, and tools, a PWA is enough and ships faster.

A short build checklist

  • HTTPS in production.
  • Manifest with name, icons including a maskable variant, theme color, and start URL.
  • Service worker that caches the shell and uses a smart strategy per request type.
  • An update flow that informs the user instead of stealing state on reload.
  • A pre-rendered offline page so failed navigations have somewhere to land.
  • Lighthouse PWA audit green.

If you build with Next.js, plugins handle the worker and manifest for you. If you ship a React app, the install button and update banner are small components that fit naturally next to your other UI, see What is React?.

Wrap up

A PWA is the same web app with two extra files and a few hundred lines of caching logic. The payoff is faster repeat visits, offline support, and an installable experience without an app store. Add the manifest, register a worker, choose your caching strategies deliberately, and most users will not notice the difference between your PWA and a native app, which is exactly the point.