Building Progressive Web Apps from Scratch

PWAs Are the Most Underused Technology on the Web. Here’s Proof.

Bold claim, right? Stick with me. By the end of this post, you’ll have built one yourself — and you’ll probably wonder why you haven’t been building them all along.

Here’s the situation. You’ve got a web app. It works fine in the browser. But your users keep asking for a “real” app. They want it on their home screen. They want it to load when they’re on a flaky train connection between Mumbai and Pune. They want push notifications. They want it to feel fast, like natively-installed-on-their-phone fast.

Your options used to be: build a React Native app, or a Flutter app, or — heaven help you — maintain separate iOS and Android codebases. Months of work. App store review processes. Two (or three) deployment pipelines.

Or. You could add three things to your existing web app and call it done.

A manifest file. A service worker. HTTPS. That’s it. That’s a Progressive Web App. And in 2026, with browser support better than it’s ever been, there’s really no good excuse not to know how to build one.

Let’s do exactly that.

First Up: The Manifest File

Think of the web app manifest as your app’s ID card. It tells the browser: “Hey, I’m not just a website. I’m an app. Here’s my name, my icon, how I want to look when someone installs me.”

It’s a JSON file. Nothing fancy. You drop it in your project root and link it in your HTML.

{
  "name": "ByteYogi Task Manager",
  "short_name": "Tasks",
  "description": "A lightweight task manager that works offline",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#2563eb",
  "orientation": "portrait-primary",
  "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-maskable.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/home.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide"
    }
  ]
}

A few things worth pointing out. The display: "standalone" setting is what makes your app launch without the browser chrome — no address bar, no tabs, just your app taking up the full screen like a native one. The maskable icon purpose matters on Android, where icons get cropped into different shapes depending on the device manufacturer. Without a maskable icon, your logo might get its edges chopped off on a Samsung phone. Not a great first impression.

Now link it up in your HTML:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="theme-color" content="#2563eb">
  <link rel="manifest" href="/manifest.json">
  <link rel="apple-touch-icon" href="/icons/icon-192.png">
  <title>ByteYogi Tasks</title>
</head>
<body>
  <div id="app">
    <h1>Task Manager</h1>
    <div id="task-list"></div>
    <form id="task-form">
      <input type="text" id="task-input" placeholder="Add a task..." required>
      <button type="submit">Add</button>
    </form>
  </div>
  <script src="/app.js"></script>
</body>
</html>

Notice the apple-touch-icon link? Safari on iOS doesn’t fully support the manifest spec (Apple’s been dragging their feet on PWA support for years — it’s gotten better but there are still gaps). That meta tag ensures iOS users get a proper icon when they add your app to their home screen.

One file down. Two to go.

The Service Worker: Where the Magic Lives

Okay, so the manifest makes your app installable. Cool. But what makes it actually work like a native app — loading offline, caching smartly, staying fast on repeat visits — is the service worker.

A service worker is a JavaScript file that runs in the background, separate from your web page. It sits between your app and the network, intercepting every fetch request. It can serve cached responses, fall back to the network, or mix both strategies depending on what’s being requested.

You register it from your main app file:

// app.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/'
      });
      console.log('Service Worker registered:', registration.scope);

      // Check for updates periodically
      setInterval(() => {
        registration.update();
      }, 60 * 60 * 1000); // every hour
    } catch (error) {
      console.error('Service Worker registration failed:', error);
    }
  });
}

// Simple task manager logic
const form = document.getElementById('task-form');
const input = document.getElementById('task-input');
const taskList = document.getElementById('task-list');

function loadTasks() {
  const tasks = JSON.parse(localStorage.getItem('tasks') || '[]');
  taskList.innerHTML = tasks.map((task, i) =>
    `<div class="task">
      <span>${task}</span>
      <button onclick="deleteTask(${i})">Delete</button>
    </div>`
  ).join('');
}

form.addEventListener('submit', (e) => {
  e.preventDefault();
  const tasks = JSON.parse(localStorage.getItem('tasks') || '[]');
  tasks.push(input.value);
  localStorage.setItem('tasks', JSON.stringify(tasks));
  input.value = '';
  loadTasks();
});

function deleteTask(index) {
  const tasks = JSON.parse(localStorage.getItem('tasks') || '[]');
  tasks.splice(index, 1);
  localStorage.setItem('tasks', JSON.stringify(tasks));
  loadTasks();
}

loadTasks();

That if ('serviceWorker' in navigator) check is your safety net. Older browsers that don’t support service workers will just skip the registration and your app works normally. No errors. No broken experiences. Progressive enhancement at its finest — the “progressive” in Progressive Web App isn’t just marketing.

Worth noting: that hourly update check matters more than you’d think. Service workers are persistent. Once installed, they stick around. If you deploy a new version of your app but the service worker doesn’t know about it, your users might be stuck on stale cached content for way longer than you’d want. The periodic update check prevents that.

Writing the Actual Service Worker

Alright, here’s where we get into the good stuff. A service worker has three lifecycle phases that you need to care about: install, activate, and fetch.

During install, you cache your static assets. During activate, you clean up old caches from previous versions. During fetch, you decide how to handle every network request your app makes.

Here’s a full implementation:

// sw.js
const CACHE_NAME = 'byteyogi-tasks-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/app.js',
  '/styles.css',
  '/icons/icon-192.png',
  '/icons/icon-512.png'
];

// Install: pre-cache static assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => {
        console.log('Caching static assets');
        return cache.addAll(STATIC_ASSETS);
      })
      .then(() => 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) => {
              console.log('Deleting old cache:', name);
              return caches.delete(name);
            })
        );
      })
      .then(() => self.clients.claim())
  );
});

// Fetch: network-first strategy for API calls,
// cache-first for static assets
self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  if (url.pathname.startsWith('/api/')) {
    // Network-first for API requests
    event.respondWith(networkFirst(request));
  } else {
    // Cache-first for static assets
    event.respondWith(cacheFirst(request));
  }
});

async function cacheFirst(request) {
  const cachedResponse = await caches.match(request);
  if (cachedResponse) {
    return cachedResponse;
  }

  try {
    const networkResponse = await fetch(request);
    if (networkResponse.ok) {
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  } catch (error) {
    return new Response('Offline', {
      status: 503,
      statusText: 'Service Unavailable'
    });
  }
}

async function networkFirst(request) {
  try {
    const networkResponse = await fetch(request);
    if (networkResponse.ok) {
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  } catch (error) {
    const cachedResponse = await caches.match(request);
    if (cachedResponse) {
      return cachedResponse;
    }
    return new Response(
      JSON.stringify({ error: 'Offline and no cached data' }),
      {
        status: 503,
        headers: { 'Content-Type': 'application/json' }
      }
    );
  }
}

Let me break down the two caching strategies here because they matter a lot.

Cache-first says: check the cache, serve what’s there, only hit the network if we don’t have it. Perfect for static stuff — your CSS, your JavaScript bundles, your images. These don’t change between page loads, so why waste a network request?

Network-first says: always try the network, use the cache only as a fallback. You want this for API calls and dynamic data. Your user’s task list should show the freshest data possible, but if they’re underground on the Delhi Metro with zero signal, at least show them the last data we fetched.

See that networkResponse.clone() call? That’s easy to miss but it’s critical. Response objects in the browser can only be consumed once. If you pass the response to the cache and also return it to the page, one of them gets nothing. The clone creates a copy so both consumers get the full response.

Stale-While-Revalidate: The Best of Both Worlds

There’s a third caching strategy that deserves its own section because it’s genuinely clever. Stale-while-revalidate serves the cached version immediately (so the user sees content right away) and simultaneously fetches a fresh copy in the background (so the next time they load, they’ll get updated content).

async function staleWhileRevalidate(request) {
  const cache = await caches.open(CACHE_NAME);
  const cachedResponse = await cache.match(request);

  const fetchPromise = fetch(request).then((networkResponse) => {
    if (networkResponse.ok) {
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  }).catch(() => cachedResponse);

  return cachedResponse || fetchPromise;
}

This works beautifully for content that changes occasionally but where showing slightly stale data for one page load is acceptable. Blog posts. User profiles. Configuration settings. Product listings that update a few times a day. Your user gets instant load times and fresh data — just not always on the same request.

When should you use which? Here’s how I think about it:

  • Cache-first: Static assets, fonts, icons, framework bundles. Things that only change when you deploy.
  • Network-first: API responses, real-time data, anything where showing stale data could cause problems (think: account balances, live scores, chat messages).
  • Stale-while-revalidate: Everything in between. Content that changes but where milliseconds of staleness won’t hurt anyone.

Most apps end up using all three. Match the strategy to the data, not the other way around.

Testing: Don’t Ship What You Haven’t Verified

Chrome DevTools has genuinely excellent PWA support. Pop open the Application tab and you can inspect your manifest, see whether the browser considers your app installable, check your service worker’s status, browse your cached resources, and even simulate offline mode to see how your app behaves when the network drops.

For automated testing, Lighthouse is your friend:

# Install Lighthouse CLI
npm install -g lighthouse

# Run a PWA audit
lighthouse https://localhost:3000 --only-categories=pwa --output=html --output-path=./pwa-report.html

Lighthouse will score your app on installability, offline support, and performance. It’ll tell you exactly what’s missing — maybe your manifest is missing a 512px icon, or your service worker doesn’t handle offline navigation, or you forgot the theme-color meta tag.

A couple of debugging tips I’ve picked up the hard way:

Service workers are aggressive. Once one is installed, it controls the page. If you’re making changes during development and nothing seems to update, go to DevTools > Application > Service Workers and hit “Unregister.” Or check “Update on reload” during development to force a fresh worker on every page load.

Clear the cache between tests. It sounds obvious. You’ll still forget. When your app is serving yesterday’s CSS despite the fact that you’ve clearly changed it — clear the cache. Application > Storage > Clear site data. Start fresh.

Test on real devices. The DevTools mobile simulator is okay for layout stuff, but actual PWA behavior — install prompts, home screen icons, offline mode, push notifications — you need a real phone for that. An old Android phone works perfectly. Just connect it via USB and use Chrome’s remote debugging.

Push Notifications: Because Your Users Asked For Them

You’ve got an installable, offline-capable app. The last piece of the native-app puzzle is push notifications. Fair warning: this is the most complex part, and it’s also the part where you need to be respectful of your users. Nobody likes an app that spams notifications. Ask permission thoughtfully and send notifications that actually matter.

The Push API works alongside the service worker. Here’s the general flow:

  1. Your app asks the user for notification permission
  2. If granted, you subscribe the user to push notifications (this gives you an endpoint URL)
  3. You send that endpoint to your server
  4. When you want to notify the user, your server sends a message to that endpoint
  5. The browser wakes up your service worker, which displays the notification

The subscription part in your app code looks something like this:

async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready;

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
  });

  // Send subscription to your server
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription)
  });
}

// Handle the push event in your service worker
self.addEventListener('push', (event) => {
  const data = event.data?.json() || {};

  event.waitUntil(
    self.registration.showNotification(data.title || 'New Update', {
      body: data.body || 'You have a new notification',
      icon: '/icons/icon-192.png',
      badge: '/icons/badge-72.png',
      data: { url: data.url || '/' }
    })
  );
});

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

VAPID keys (Voluntary Application Server Identification) are how browsers verify that your server is authorized to send push messages. You generate a key pair once, use the public key on the client side, and the private key on your server when sending push messages. The web-push npm package handles most of this for you on the server side.

Real-World PWA Performance: The Numbers

I wouldn’t blame you for wondering whether all this effort is worth it. Fair question. Let’s look at some numbers.

Twitter Lite (one of the earliest high-profile PWAs) reported a 65% increase in pages per session, 75% more tweets sent, and a 20% decrease in bounce rate after launching as a PWA. Pinterest’s PWA saw ad revenue jump by 44% and time spent on the site go up by 40%. Starbucks reported that their PWA is 99.84% smaller than their native iOS app while delivering the same core ordering functionality.

These are big companies with big engineering teams, sure. But the patterns work the same at any scale. A local restaurant in Jaipur with a menu and ordering app gets the same benefits: installability, offline access, and fast loads. Probably more, actually, because their users are more likely to be on spotty connections and low-end devices.

On the Indian web specifically — where Jio’s network can fluctuate wildly depending on location, where many users are still on budget Android phones with limited storage, where data costs matter — PWAs make a ridiculous amount of sense. No app store download. No 50MB install. Just a website that happens to work offline and sit on your home screen.

So, About That Bold Claim

At the top of this post, I said PWAs are the most underused technology on the web. Let’s revisit that.

You’ve just seen what it takes to build one: a JSON manifest, a service worker with some caching logic, and HTTPS (which you should already have). No framework required. No build tool required. No app store required. The code we wrote today would work in any web app — React, Vue, Svelte, plain HTML, doesn’t matter.

And what do you get for that modest investment? Installability. Offline support. Push notifications. Faster load times on repeat visits. An app-like experience that works across every platform with a modern browser.

Yet most web apps don’t bother. Most developers don’t even consider it. They jump straight to “we need a native app” and spend months and thousands of dollars building something that a service worker and a manifest could’ve handled in an afternoon.

I’m not saying PWAs replace native apps for everything. Games with heavy GPU requirements, apps that need deep OS integration, anything involving Bluetooth or NFC — native still wins there. But for the vast majority of web applications? The ones that display data, handle forms, show notifications, and need to work offline? PWAs aren’t just good enough. For a lot of use cases, they’re actually better.

Installable. Offline-capable. Lightweight. Cross-platform. Built with skills you already have.

Underused? Massively. And now you’ve got no excuse.

Leave a Comment

Your email address will not be published. Required fields are marked with an asterisk.