Service Worker and PWA: Building Offline-First Web Apps in 2026

Developer May 30, 2026 · OTPZap Team

In 2026, the gap between web apps and native apps is getting thinner. Twitter (X) is mostly PWA. Spotify Web feels like native app. Notion Desktop is actually a PWA wrapper. Even iOS finally gives PWA proper home screen support.

The key behind all of this: Service Workers. But many web developers haven't used Service Workers because they're considered complex. In 2026, tools and patterns have matured. Setting up proper PWA has become much easier.

This article is a practical guide to Service Workers and PWAs from a 2026 developer perspective: when worth investing, common patterns, and limitations still present.

What is a Service Worker

Service Worker is JavaScript that runs in the background, separate from the main browser tab thread. It intercepts network requests and can respond with cached data, or modify responses. It also handles push notifications and background sync.

Service Worker lifecycle:

  1. Register: main thread calls navigator.serviceWorker.register('/sw.js')
  2. Install: SW downloaded and installed. At this time can cache static assets.
  3. Activate: SW activated. Old SW (if any) replaced.
  4. Idle: SW dormant, terminates after some time.
  5. Wakeup: SW woken up when there's an event (fetch, push, sync).

Important: SW lives independent of tab. Even if tab is closed, SW can still receive push notifications and trigger background sync.

Why Service Workers are Game-Changers

1. Offline Capability

Cache static assets and API data. Users offline can still access most features. Critical for mobile users in Indonesia where connectivity is unreliable.

2. Performance Boost

Assets cached locally = instant load on repeat visits. Network requests optional, not critical path. App feels more responsive.

3. Push Notifications

Web push notifications run via Service Worker. Users can be engaged even when not opening tab. App-like engagement.

4. Background Sync

User submits form while offline, queue in IndexedDB. When online, background sync sends data without user opening app. Messaging-style reliability.

5. Update Mechanism

SW handles versioning. Update detected, installed in background, activated when user reloads. Better user experience than force refresh.

Service Worker Setup: Minimal Working Example

Here's barebone SW that caches static assets:

// sw.js
const CACHE_NAME = 'app-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/logo.png'
];

// Install: cache static assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(STATIC_ASSETS);
    })
  );
  self.skipWaiting(); // become active immediate
});

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

// Fetch: cache-first for static, network-first for API
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  
  if (url.pathname.startsWith('/api/')) {
    // Network first for API
    event.respondWith(
      fetch(event.request).catch(() => caches.match(event.request))
    );
  } else {
    // Cache first for static
    event.respondWith(
      caches.match(event.request).then((cached) => {
        return cached || fetch(event.request);
      })
    );
  }
});

Register in main thread:

// app.js
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(reg => console.log('SW registered'))
    .catch(err => console.error('SW failed:', err));
}

Caching Strategies: Pick the Right One

Common caching patterns:

1. Cache First

Check cache first, if exists serve from cache. If not, fetch from network and cache.

Use case: static assets (CSS, JS, image). Assets that don't change often.

2. Network First

Try fetch network first. If success, cache and serve. If fail (offline), fallback to cache.

Use case: API responses where freshness matters. News feed, dashboard data.

3. Stale While Revalidate

Serve from cache immediately (stale OK). Meanwhile, fetch network update in background. Next request gets fresh data.

Use case: balanced freshness vs speed. User profile, frequently-accessed data.

4. Network Only

Always fetch from network. No cache.

Use case: critical data that must be realtime (banking transactions, payments).

5. Cache Only

Always serve from cache. No network.

Use case: pre-cached static assets, app shell.

Use Workbox: Less Boilerplate

Workbox (by Google) is a library abstracting caching strategies. Declarative configuration, auto-generates SW.

// workbox-config.js
module.exports = {
  globDirectory: 'dist/',
  globPatterns: ['**/*.{html,js,css,png,jpg}'],
  swDest: 'dist/sw.js',
  
  runtimeCaching: [{
    urlPattern: /^https:\/\/api\.yoursite\.com/,
    handler: 'NetworkFirst',
    options: {
      cacheName: 'api-cache',
      expiration: { maxAgeSeconds: 60 * 5 } // 5 minutes
    }
  }, {
    urlPattern: /\.(png|jpg|jpeg|svg)$/,
    handler: 'CacheFirst',
    options: {
      cacheName: 'image-cache',
      expiration: { maxAgeSeconds: 60 * 60 * 24 * 30 } // 30 days
    }
  }]
};

Build via workbox-cli generates optimized SW. For medium to large projects, this saves a lot of time vs hand-rolling.

Push Notification Implementation

Web push notifications need 3 components:

1. Subscribe User

const reg = await navigator.serviceWorker.ready;
const subscription = await reg.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: VAPID_PUBLIC_KEY
});

// Send subscription to server
await fetch('/api/subscribe', {
  method: 'POST',
  body: JSON.stringify(subscription)
});

2. Server Sends Push

// Node.js example using web-push library
const webPush = require('web-push');

webPush.setVapidDetails(
  'mailto:[email protected]',
  VAPID_PUBLIC_KEY,
  VAPID_PRIVATE_KEY
);

await webPush.sendNotification(subscription, JSON.stringify({
  title: 'New message',
  body: 'You have unread messages',
  icon: '/icon.png',
  url: '/inbox'
}));

3. SW Handles Push Event

self.addEventListener('push', (event) => {
  const data = event.data.json();
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: data.icon,
      data: { url: data.url }
    })
  );
});

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

Use cases: chat apps, marketplace order updates, important news. Important: don't over-use, users can block notifications from your domain permanently.

Background Sync

User submits form while offline. Naive: give error. Better: queue in IndexedDB, sync when online.

// Trigger sync
const reg = await navigator.serviceWorker.ready;
await reg.sync.register('sync-form-submission');

// In SW
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-form-submission') {
    event.waitUntil(syncQueuedForms());
  }
});

async function syncQueuedForms() {
  const db = await openDB('queue', 1);
  const queue = await db.getAll('forms');
  
  for (const form of queue) {
    try {
      await fetch('/api/submit', {
        method: 'POST',
        body: JSON.stringify(form.data)
      });
      await db.delete('forms', form.id);
    } catch (err) {
      // Retry next sync
      console.error('Sync failed', err);
    }
  }
}

Browser automatically triggers sync when online. Reliable mechanism for eventual consistency.

PWA: Installable Web Apps

PWA = Progressive Web App. Web app fulfilling specific criteria can be "installed" as app on home screen.

Requirements:

  1. Manifest file: manifest.json with app name, icon, theme color, display mode
  2. Service Worker registered
  3. HTTPS (or localhost for dev)
  4. Icons defined in multiple sizes

Manifest example:

{
  "name": "OTPZap",
  "short_name": "OTPZap",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0c0e16",
  "theme_color": "#a566ff",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

Browser detects criteria, gives "Install app" prompt. User installs, app appears on home screen, runs in standalone window (no browser UI).

When PWA Can Replace Native App

YES, PWA enough if:

NO, native app needed if:

iOS Limitations Still Present

Apple historically restricts PWA capabilities on iOS. Improvements in iOS 17/18, but gaps remain:

For iOS-heavy user base, native app via Capacitor or React Native is still more complete option.

Closing

Service Workers and PWAs have matured more in 2026 than 5 years ago. Libraries like Workbox abstract complexity. Browser support universal. iOS finally supports properly.

For new web apps, PWA should be default consideration. Extra effort minimal with Workbox. Benefits: offline capability, faster repeat visits, installable. You get 80% native app functionality with 20% effort.

For apps already live, evaluate one feature at a time. Start with static asset caching (biggest impact for speed). Add API caching if it makes sense. Push notifications if use case fits.

What you shouldn't do: build PWA "for fancy". You need real use case where users get benefit. Otherwise, additional complexity isn't worth it.