Service Worker and PWA: Building Offline-First Web Apps in 2026
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:
- Register: main thread calls
navigator.serviceWorker.register('/sw.js') - Install: SW downloaded and installed. At this time can cache static assets.
- Activate: SW activated. Old SW (if any) replaced.
- Idle: SW dormant, terminates after some time.
- 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:
- Manifest file:
manifest.jsonwith app name, icon, theme color, display mode - Service Worker registered
- HTTPS (or localhost for dev)
- 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:
- App primarily content delivery (news, blog, e-commerce, dashboard)
- Functionality achievable with web APIs (push notifications, geolocation, basic camera, payments)
- No deep OS integration needed
- Cross-platform without native team
NO, native app needed if:
- Heavy AR / VR / 3D processing
- Advanced Bluetooth (BLE pairing, custom protocols)
- Long background processes (periodic sync, location tracking)
- Native widgets on home screen
- Tight OS feature integration (Siri shortcuts, App Clip, Apple Watch)
- iOS-specific features Apple blocks from web (NFC payment, etc)
iOS Limitations Still Present
Apple historically restricts PWA capabilities on iOS. Improvements in iOS 17/18, but gaps remain:
- Storage limit per origin (50MB-1GB depending on available space)
- Push notifications: supported, but user must "Add to Home Screen" first
- Background sync: limited
- Bluetooth, NFC, USB: still unsupported on web
- App Clip equivalent: doesn't exist
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.