Skip to main content
{/}codefunc
GuidesJuly 7, 2026 · 8 min read

Progressive Web Apps: Service Workers, Offline-First, and Installation

A practical guide to building Progressive Web Apps — service workers for offline support, web app manifest for installation, caching strategies, and turning your website into an installable app.

By codefunc

pwaservice-workerofflineweb-appcachingfrontend

Progressive Web Apps bridge the gap between websites and native apps. They load like regular web pages but offer offline support, push notifications, and home screen installation. PWAs work on any device with a modern browser — no app store required.

Bottom line: A PWA needs three things: HTTPS, a web app manifest (build one with the Web Manifest Builder), and a service worker. Pair icons with the Favicon Generator. The manifest enables installation. The service worker enables offline support and caching. Start with a caching strategy that fits your content: network-first for dynamic data, cache-first for static assets.

What makes a PWA

Feature Implementation
Installable Web app manifest
Offline capable Service worker with caching
Secure HTTPS required
Responsive CSS media queries
App-like Full-screen mode, navigation
Fresh Service worker updates

Web app manifest

The manifest tells browsers how to install and display your app.

{
  "name": "My Awesome App",
  "short_name": "MyApp",
  "description": "An awesome progressive web app",
  "start_url": "/",
  "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/home.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide"
    },
    {
      "src": "/screenshots/mobile.png",
      "sizes": "750x1334",
      "type": "image/png",
      "form_factor": "narrow"
    }
  ]
}
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#2563eb">
<link rel="apple-touch-icon" href="/icons/icon-192.png">

Display modes

Mode Description
fullscreen No browser UI, entire screen
standalone App-like, no URL bar
minimal-ui Minimal browser controls
browser Standard browser tab

Icon requirements

  • 192x192: Required for Android install
  • 512x512: Required for splash screen
  • Maskable icons: Safe zone for adaptive icons

Service workers

Service workers intercept network requests, enabling offline support and caching.

Basic service worker

// sw.js
const CACHE_NAME = 'my-app-v1';
const urlsToCache = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/offline.html',
];

// Install - cache static assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => cache.addAll(urlsToCache))
  );
});

// Activate - clean 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))
      );
    })
  );
});

// Fetch - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => response || fetch(event.request))
      .catch(() => caches.match('/offline.html'))
  );
});

Register service worker

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

Caching strategies

Cache-first (static assets)

Best for: CSS, JS, images, fonts that rarely change.

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

Network-first (dynamic content)

Best for: API responses, user-specific data.

self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        // Cache fresh 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

Best for: Content that should be fresh but can show stale briefly.

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

Strategy by route

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  
  // API requests: network-first
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request));
    return;
  }
  
  // Static assets: cache-first
  if (url.pathname.match(/\.(css|js|png|jpg|svg|woff2)$/)) {
    event.respondWith(cacheFirst(event.request));
    return;
  }
  
  // HTML pages: network-first with offline fallback
  event.respondWith(
    fetch(event.request)
      .catch(() => caches.match('/offline.html'))
  );
});

Workbox

Workbox is a library that simplifies service worker development.

npm install workbox-webpack-plugin

Workbox with Vite

// vite.config.js
import { VitePWA } from 'vite-plugin-pwa';

export default {
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.example\.com\/.*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: {
                maxEntries: 50,
                maxAgeSeconds: 60 * 60 * 24, // 1 day
              },
            },
          },
        ],
      },
      manifest: {
        name: 'My App',
        short_name: 'MyApp',
        theme_color: '#2563eb',
        icons: [
          {
            src: '/icon-192.png',
            sizes: '192x192',
            type: 'image/png',
          },
          {
            src: '/icon-512.png',
            sizes: '512x512',
            type: 'image/png',
          },
        ],
      },
    }),
  ],
};

Workbox strategies

import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';

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

// API: network-first
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-responses',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // 5 minutes
      }),
    ],
  })
);

// CSS/JS: stale-while-revalidate
registerRoute(
  ({ request }) => 
    request.destination === 'style' || 
    request.destination === 'script',
  new StaleWhileRevalidate({
    cacheName: 'static-resources',
  })
);

Installation prompt

Custom install button

let deferredPrompt;

window.addEventListener('beforeinstallprompt', (e) => {
  // Prevent default prompt
  e.preventDefault();
  deferredPrompt = e;
  
  // Show your custom install button
  document.getElementById('install-button').style.display = 'block';
});

document.getElementById('install-button').addEventListener('click', async () => {
  if (!deferredPrompt) return;
  
  // Show native prompt
  deferredPrompt.prompt();
  
  // Wait for user response
  const { outcome } = await deferredPrompt.userChoice;
  console.log(`User response: ${outcome}`);
  
  deferredPrompt = null;
  document.getElementById('install-button').style.display = 'none';
});

// Detect if already installed
window.addEventListener('appinstalled', () => {
  console.log('PWA installed');
  deferredPrompt = null;
});

Check if running as PWA

// Check display mode
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
const isFromHomeScreen = window.navigator.standalone; // iOS

if (isStandalone || isFromHomeScreen) {
  console.log('Running as installed PWA');
}

Offline UI patterns

Show offline indicator

// Detect online/offline
window.addEventListener('online', () => {
  document.body.classList.remove('offline');
});

window.addEventListener('offline', () => {
  document.body.classList.add('offline');
});

// Initial check
if (!navigator.onLine) {
  document.body.classList.add('offline');
}
.offline-indicator {
  display: none;
  position: fixed;
  bottom: 0;
  width: 100%;
  padding: 0.5rem;
  background: #fef3c7;
  text-align: center;
}

.offline .offline-indicator {
  display: block;
}

Queue actions for sync

// Save action for later
async function saveForSync(action) {
  const queue = JSON.parse(localStorage.getItem('syncQueue') || '[]');
  queue.push(action);
  localStorage.setItem('syncQueue', JSON.stringify(queue));
}

// Process queue when online
window.addEventListener('online', async () => {
  const queue = JSON.parse(localStorage.getItem('syncQueue') || '[]');
  
  for (const action of queue) {
    try {
      await fetch(action.url, action.options);
    } catch (error) {
      console.error('Sync failed:', error);
      return; // Stop on failure
    }
  }
  
  localStorage.setItem('syncQueue', '[]');
});

Background sync

// Register sync (in main app)
async function savePost(post) {
  // Save to IndexedDB
  await saveToIndexedDB('posts', post);
  
  // Register sync if offline
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('sync-posts');
  }
}

// Handle sync (in service worker)
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-posts') {
    event.waitUntil(syncPosts());
  }
});

async function syncPosts() {
  const posts = await getFromIndexedDB('posts');
  
  for (const post of posts) {
    await fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify(post),
    });
    await deleteFromIndexedDB('posts', post.id);
  }
}

Push notifications

Request permission

async function requestNotificationPermission() {
  const permission = await Notification.requestPermission();
  
  if (permission === 'granted') {
    const registration = await navigator.serviceWorker.ready;
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
    });
    
    // Send subscription to server
    await fetch('/api/subscribe', {
      method: 'POST',
      body: JSON.stringify(subscription),
    });
  }
}

Handle push (service worker)

self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {};
  
  event.waitUntil(
    self.registration.showNotification(data.title || 'Notification', {
      body: data.body,
      icon: '/icon-192.png',
      badge: '/badge.png',
      data: { url: data.url },
    })
  );
});

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

Service worker updates

// Detect update available
navigator.serviceWorker.ready.then((registration) => {
  registration.addEventListener('updatefound', () => {
    const newWorker = registration.installing;
    
    newWorker.addEventListener('statechange', () => {
      if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
        // New version available
        showUpdateBanner();
      }
    });
  });
});

function showUpdateBanner() {
  const banner = document.createElement('div');
  banner.innerHTML = `
    <p>New version available!</p>
    <button onclick="updateApp()">Update</button>
  `;
  document.body.appendChild(banner);
}

function updateApp() {
  navigator.serviceWorker.ready.then((registration) => {
    registration.waiting?.postMessage({ type: 'SKIP_WAITING' });
  });
  window.location.reload();
}
// Service worker - handle skip waiting
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

Testing PWAs

Lighthouse audit

DevTools → Lighthouse → Check "Progressive Web App"

Application panel

DevTools → Application:

  • Manifest: Check for errors
  • Service Workers: See registered workers
  • Cache Storage: Inspect cached files

Simulate offline

DevTools → Network → Check "Offline"

Use these codefunc tools alongside this guide:

Practical takeaway

Start simple: manifest for installation, basic service worker for offline. Cache static assets with cache-first, dynamic content with network-first. Use Workbox to simplify service worker code. Test with Lighthouse and in offline mode. Add push notifications only if they provide real user value. Remember that PWAs enhance progressively — they still work without service worker support.

Sources

Try it on codefunc

Related articles

3

Search tools

Find a developer tool