Skip to main content
← All Articles · · 12 min read

Progressive Web Apps Complete Guide 2026: Service Workers, Offline-First, and Beyond

The definitive PWA guide for 2026. Learn service workers, Web App Manifest, offline-first strategies, push notifications, installability, and how PWAs compare to native apps—with real code examples.

progressive-web-appspwaservice-workeroffline-web-apppwa-tutorialweb-developmentperformance

Progressive Web Apps aren’t new. Google coined the term in 2015. But in 2026, the gap between a well-built PWA and a native app has narrowed to the point where “should we build an app?” is often the wrong question. The right question is: “have we made our web app as capable as it can be?”

This guide covers everything you need to build a production-grade PWA in 2026: service workers from scratch, the Web App Manifest, offline-first data strategies, push notifications, and an honest comparison of where PWAs still fall short versus native.


What Makes a Web App “Progressive”

A Progressive Web App is a web application that uses modern browser APIs to deliver app-like capabilities:

  • Installable: Users can add it to their home screen or desktop
  • Offline-capable: Works without a network connection (or with a degraded but functional experience)
  • Fast: Loads quickly even on slow connections
  • Push-enabled: Can receive notifications even when the browser is closed
  • Secure: Served over HTTPS (required for service workers)

These aren’t binary—your app can adopt any of these features incrementally. That’s the “progressive” part. Start with what makes sense for your users, add more as the value becomes clear.


The Service Worker: The Heart of PWAs

A service worker is a JavaScript file that runs in a separate thread from your main application. It acts as a programmable proxy between your app and the network, intercepting requests and deciding how to respond.

Registering a Service Worker

// main.js (your app entry point)
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);
    } catch (error) {
      console.error('Service worker registration failed:', error);
    }
  });
}

The scope controls which URLs the service worker can intercept. A service worker at /sw.js with scope / controls the entire origin. One at /app/sw.js with scope /app/ only controls URLs under /app/.

The Service Worker Lifecycle

Understanding the lifecycle prevents the most common PWA bugs:

Install → Activate → Fetch/Message/Push

Install: Triggered when the browser first encounters a new (or updated) service worker. Use this to pre-cache critical assets.

Activate: Fires after install, once no old service worker is controlling any clients. Use this to clean up old caches.

Fetch: Fires for every network request made by pages in scope. This is where you implement caching strategies.

// sw.js
const CACHE_NAME = 'devplaybook-v3';
const PRECACHE_URLS = [
  '/',
  '/offline.html',
  '/styles/main.css',
  '/scripts/app.js',
];

// Install: pre-cache critical assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(PRECACHE_URLS);
    })
  );
  // Activate immediately, don't wait for old SW to go away
  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))
      );
    })
  );
  // Take control of all clients immediately
  self.clients.claim();
});

Caching Strategies

The right caching strategy depends on the resource type. Here are the five patterns you’ll use in production:

1. Cache First (for static assets)

Check the cache first. Only fetch from network if not found. Best for versioned assets (CSS, JS with hashes in filenames) that rarely change.

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

  const response = await fetch(request);
  const cache = await caches.open(CACHE_NAME);
  cache.put(request, response.clone());
  return response;
}

2. Network First (for API data)

Try the network. Fall back to cache on failure. Best for dynamic data where freshness matters.

async function networkFirst(request) {
  try {
    const response = await fetch(request);
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
    return response;
  } catch (error) {
    const cached = await caches.match(request);
    if (cached) return cached;
    // Return offline fallback
    return caches.match('/offline.html');
  }
}

3. Stale-While-Revalidate (for content that updates regularly)

Return from cache immediately (fast), then fetch from network in the background to update the cache for next time. Best for content that updates regularly but where stale data is acceptable.

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

  // Fetch in background regardless
  const fetchPromise = fetch(request).then((response) => {
    cache.put(request, response.clone());
    return response;
  });

  // Return cache immediately if available, else wait for fetch
  return cached || fetchPromise;
}

4. Cache Only (for offline-first assets)

Never hit the network. Only serve from cache. Use for assets you’ve explicitly pre-cached.

5. Network Only (for non-cacheable requests)

Never use the cache. Always go to network. Use for analytics, payment endpoints, and other requests where stale data would be harmful.

Putting It Together: Route-Based Strategy Selection

self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // Skip non-GET and cross-origin requests
  if (request.method !== 'GET') return;
  if (url.origin !== self.location.origin) return;

  // API routes: network first
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request));
    return;
  }

  // Static assets: cache first
  if (url.pathname.match(/\.(js|css|woff2|png|jpg|svg)$/)) {
    event.respondWith(cacheFirst(request));
    return;
  }

  // HTML pages: stale-while-revalidate
  event.respondWith(staleWhileRevalidate(request));
});

Web App Manifest

The manifest file tells the browser how to present your app when installed. It’s a JSON file linked from your HTML.

<!-- index.html -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb">
{
  "name": "DevPlaybook",
  "short_name": "DevPlaybook",
  "description": "Developer tools and references, always available",
  "start_url": "/?source=pwa",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#2563eb",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide",
      "label": "Desktop view"
    },
    {
      "src": "/screenshots/mobile.png",
      "sizes": "390x844",
      "type": "image/png",
      "form_factor": "narrow",
      "label": "Mobile view"
    }
  ],
  "shortcuts": [
    {
      "name": "Tools",
      "url": "/tools",
      "icons": [{ "src": "/icons/tools.png", "sizes": "96x96" }]
    }
  ]
}

Key display values:

  • standalone: Looks like a native app (no browser UI)
  • minimal-ui: Browser UI with minimal controls
  • fullscreen: Full screen (no browser chrome at all)
  • browser: Regular browser tab (no PWA feel)

The screenshots array enables richer install prompts on Android and some desktop platforms. The shortcuts array adds quick actions to the app icon’s context menu.


Offline-First Data Strategies

Making your UI available offline is the easy part. Making your data available offline—and syncing changes made while offline—is where real offline-first gets complex.

IndexedDB for Persistent Storage

localStorage is synchronous and limited to strings. For offline data storage, use IndexedDB (or a library like Dexie.js that wraps it).

// Using Dexie.js for readable IndexedDB access
import Dexie from 'dexie';

const db = new Dexie('DevPlaybookDB');
db.version(1).stores({
  articles: '++id, slug, title, updatedAt, *tags',
  syncQueue: '++id, url, method, body, createdAt'
});

// Save article for offline reading
export async function cacheArticle(article) {
  await db.articles.put(article);
}

// Get all cached articles
export async function getCachedArticles() {
  return db.articles.orderBy('updatedAt').reverse().toArray();
}

Background Sync for Deferred Actions

Background Sync lets you queue actions (form submissions, likes, data writes) that failed due to network loss, and replay them automatically when connectivity returns.

// In your app: queue the action when offline
async function submitFormWithSync(formData) {
  if (!navigator.onLine) {
    await db.syncQueue.add({
      url: '/api/submissions',
      method: 'POST',
      body: JSON.stringify(formData),
      createdAt: Date.now()
    });

    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('submit-form');
    return { queued: true };
  }

  return fetch('/api/submissions', {
    method: 'POST',
    body: JSON.stringify(formData)
  });
}

// In sw.js: replay queued actions when online
self.addEventListener('sync', (event) => {
  if (event.tag === 'submit-form') {
    event.waitUntil(replayQueue());
  }
});

async function replayQueue() {
  const queued = await db.syncQueue.orderBy('createdAt').toArray();
  for (const item of queued) {
    try {
      await fetch(item.url, {
        method: item.method,
        body: item.body,
        headers: { 'Content-Type': 'application/json' }
      });
      await db.syncQueue.delete(item.id);
    } catch (err) {
      // Will retry on next sync event
      break;
    }
  }
}

Push Notifications

Push notifications work even when the browser is closed. The flow:

  1. User grants permission
  2. Browser subscribes to a push service (each browser has its own)
  3. Your server gets the subscription endpoint
  4. Your server sends push messages to that endpoint
  5. Service worker receives the message and shows a notification

Requesting Permission and Subscribing

// app.js
async function subscribeToPush() {
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return null;

  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true, // Required: must show notification for every push
    applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY)
  });

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

  return subscription;
}

function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  const rawData = atob(base64);
  return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}

Generating VAPID Keys

# Install web-push package
npm install web-push

# Generate VAPID keys
npx web-push generate-vapid-keys

Handling Push in the Service Worker

// sw.js
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {};

  event.waitUntil(
    self.registration.showNotification(data.title || 'DevPlaybook', {
      body: data.body,
      icon: '/icons/icon-192.png',
      badge: '/icons/badge-72.png',
      data: { url: data.url || '/' },
      actions: [
        { action: 'open', title: 'Open' },
        { action: 'dismiss', title: 'Dismiss' }
      ]
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  if (event.action === 'dismiss') return;

  const url = event.notification.data?.url || '/';
  event.waitUntil(
    clients.matchAll({ type: 'window' }).then((windowClients) => {
      const client = windowClients.find((c) => c.url === url && 'focus' in c);
      if (client) return client.focus();
      return clients.openWindow(url);
    })
  );
});

Sending Push from Your Server

// server.js (Node.js)
import webpush from 'web-push';

webpush.setVapidDetails(
  'mailto:[email protected]',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

export async function sendPushNotification(subscription, payload) {
  try {
    await webpush.sendNotification(subscription, JSON.stringify(payload));
  } catch (error) {
    if (error.statusCode === 410) {
      // Subscription expired — remove it from your database
      await removeSubscription(subscription.endpoint);
    }
    throw error;
  }
}

PWA Installability Requirements

To trigger the browser’s install prompt, your app must meet these criteria (Chrome/Edge):

  1. Served over HTTPS (or localhost for dev)
  2. Has a valid Web App Manifest with name, short_name, start_url, display (standalone/fullscreen/minimal-ui), and at least a 192x192 icon
  3. Has a registered service worker with a fetch handler
  4. Hasn’t been installed already

You can capture the beforeinstallprompt event to show your own install UI:

let deferredPrompt;

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

async function installApp() {
  if (!deferredPrompt) return;
  deferredPrompt.prompt();
  const { outcome } = await deferredPrompt.userChoice;
  console.log(`Install outcome: ${outcome}`); // 'accepted' or 'dismissed'
  deferredPrompt = null;
  hideInstallButton();
}

window.addEventListener('appinstalled', () => {
  console.log('PWA installed');
  // Track installation in analytics
});

Performance Optimization for PWAs

A PWA that installs well but loads slowly defeats the purpose. Key optimizations:

Preloading Critical Resources

<link rel="preload" href="/styles/main.css" as="style">
<link rel="preload" href="/scripts/app.js" as="script">
<link rel="preconnect" href="https://api.example.com">

App Shell Pattern

Separate your “shell” (navigation, layout, chrome) from your content. Pre-cache the shell; load content dynamically.

// Shell routes get cache-first treatment
const SHELL_URLS = ['/', '/tools', '/articles', '/offline.html'];

// Content gets network-first with cache fallback
self.addEventListener('fetch', (event) => {
  if (SHELL_URLS.includes(new URL(event.request.url).pathname)) {
    event.respondWith(cacheFirst(event.request));
  } else {
    event.respondWith(networkFirst(event.request));
  }
});

Workbox for Production Use

Writing service worker caching logic by hand is error-prone. Workbox (by Google) handles edge cases you’ll miss:

// sw.js with Workbox
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute } from 'workbox-precaching';

// Precache assets injected by build tool
precacheAndRoute(self.__WB_MANIFEST);

// Images: cache first, expire after 30 days
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [new ExpirationPlugin({ maxAgeSeconds: 30 * 24 * 60 * 60 })]
  })
);

// API: network first, fall back to cache
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({ cacheName: 'api-data' })
);

// Pages: stale-while-revalidate
registerRoute(
  ({ request }) => request.mode === 'navigate',
  new StaleWhileRevalidate({ cacheName: 'pages' })
);

PWA vs Native Apps: An Honest Comparison in 2026

CapabilityPWANative (iOS/Android)
Push notifications✅ All platforms (iOS 16.4+)✅
Offline support✅ Full control✅
Camera / microphone✅✅
Bluetooth / NFC⚠️ Chrome only✅
Background sync✅✅
App store distribution⚠️ via TWA (Android), limited iOS✅
Deep system integration❌✅
Biometric auth✅ WebAuthn✅
In-app purchases❌✅
AR / VR⚠️ WebXR (limited)✅
Install prompt (iOS)⚠️ Manual only✅
Development cost✅ One codebase❌ Two codebases

Choose PWA when: Your app is content-heavy, cross-platform reach matters more than deep OS integration, you want faster iteration cycles, or budget constrains you to one codebase.

Choose native when: You need app store discovery, in-app purchases, advanced hardware access (ARKit, Bluetooth, background location), or your users expect platform-specific UX conventions.

The hybrid play: Build a PWA first, then wrap it as a Trusted Web Activity (TWA) for Google Play distribution. You get the Play Store listing without maintaining a separate codebase.


Testing Your PWA

Lighthouse Audit

Chrome DevTools includes Lighthouse, which runs a PWA audit:

  1. Open DevTools → Lighthouse tab
  2. Check “Progressive Web App” category
  3. Run audit
  4. Look for the PWA installability checklist and best practices score

Testing Offline Behavior

# Simulate offline in DevTools:
# Network tab → Throttling dropdown → Offline

# Or test with Workbox's dev server
npx workbox wizard

Testing Push Notifications

Chrome DevTools → Application → Service Workers → Push button sends a test push event to your service worker without needing a server.


Common PWA Pitfalls

1. Cache poisoning: Never cache error responses. Always check response.ok before caching.

const response = await fetch(request);
if (response.ok) {
  cache.put(request, response.clone());
}
return response;

2. skipWaiting() timing: Calling skipWaiting() during install activates the new service worker immediately—while old clients are still running with the old version. For most apps this is fine. For apps with strict cache consistency requirements, wait for all clients to close.

3. opaque responses: Cross-origin responses without CORS headers are “opaque”—you can cache them but you can’t inspect or check their status. They always appear as status 0.

4. iOS limitations: As of 2026, iOS Safari supports service workers, but the install experience requires users to use “Add to Home Screen” from the Share menu—there’s no automatic install prompt. Notification permission on iOS requires a PWA to already be installed to the home screen.

5. Cache size limits: Browsers impose per-origin storage limits (varies by browser and available disk space). Use the Storage Manager API to check:

const estimate = await navigator.storage.estimate();
console.log(`Using ${estimate.usage} of ${estimate.quota} bytes`);

Quick Start Checklist

Use this to audit your own app:

  • Served over HTTPS
  • manifest.json linked in HTML with required fields
  • Icons at 192x192 and 512x512 (maskable variants)
  • Service worker registered with fetch handler
  • Offline fallback page at /offline.html
  • Critical assets pre-cached on install
  • beforeinstallprompt captured for custom install UI
  • Push subscription flow implemented (if applicable)
  • Cache cleanup in activate event
  • Lighthouse PWA audit passes
  • Tested with DevTools Network throttling set to Offline

Conclusion

Progressive Web Apps in 2026 are a genuine alternative to native apps for a wide class of products. The core APIs—service workers, Web App Manifest, Background Sync, Push—are stable, well-supported, and well-documented. The remaining gaps (iOS install flow, in-app purchases, deep hardware access) are real, but narrower than they were two years ago.

The best argument for PWAs isn’t that they’re “as good as native.” It’s that they’re already what most users need, available without an app store, updatable instantly, and buildable with the skills your team already has.

Start with a service worker and a manifest. Ship the offline fallback. Add push notifications if your use case benefits from them. Audit with Lighthouse. The rest follows naturally.


Looking for related tools? Check out Lighthouse CI for automated PWA auditing, Workbox for production-ready service worker patterns, and PWA Builder for packaging PWAs for app store submission.

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.