Offline-First PWAs: Service Worker Caching Strategies

featured article thumbnail

Offline-first PWAs are progressive web apps architected to function seamlessly without internet connectivity by using service workers to cache critical assets and data, ensuring users can access content even during network failures or in areas with poor connectivity. Service worker caching strategies—including cache-first, network-first, and stale-while-revalidate—determine how PWAs handle resource requests, balance freshness with performance, and provide resilient user experiences across varying network conditions.

Building offline-capable applications used to require native development with complex synchronization logic and local database management. Progressive web apps changed this by bringing service workers to the web—programmable network proxies that intercept requests and implement sophisticated caching strategies with JavaScript.

This comprehensive guide explores offline-first architecture, service worker fundamentals, and the caching strategies that power resilient PWAs. Whether you're building your first offline-capable application or optimizing an existing PWA, you'll learn how to implement, test, and debug service worker caching for production applications.

What is Offline-First Architecture?

Offline-first is a design philosophy that prioritizes resilience and performance by assuming network connectivity is unreliable or absent. Rather than treating offline capability as an edge case, offline-first architecture makes it the default behavior.

Traditional vs. Offline-First Approach

Traditional Online-First:

  1. Application requests data from network
  2. If network fails, show error message
  3. User experience breaks without connectivity
  4. No fallback or cached content

Offline-First:

  1. Application checks local cache first
  2. Serves cached content immediately
  3. Fetches updates in background when network available
  4. Synchronizes changes when connectivity returns

This paradigm shift transforms PWAs from fragile web pages into resilient applications that work reliably regardless of network conditions.

Benefits of Offline-First PWAs

1. Resilience During Network Failures

Users never see "You are offline" error screens. The application continues functioning with cached data, providing graceful degradation rather than complete failure.

2. Improved Performance

Serving assets from cache is dramatically faster than network requests:

  • Cache response: 5-20ms
  • Network response on 3G: 500-2000ms
  • Network response on WiFi: 50-200ms

Offline-first PWAs feel instantaneous even on slow networks.

3. Reduced Server Load

Caching assets locally reduces bandwidth consumption and server requests. For applications with millions of users, this translates to significant infrastructure savings.

4. Emerging Market Accessibility

In regions with spotty connectivity or expensive data plans, offline-first PWAs provide equitable access to digital services that would otherwise be unavailable.

5. Better User Experience

Users expect applications to work. Offline-first PWAs meet this expectation by eliminating the distinction between online and offline experiences.

When Offline-First Makes Sense

Offline-first architecture is ideal for:

  • Content-heavy applications - News, documentation, blogs where content can be pre-cached
  • Productivity tools - Note-taking, task managers, calendars with eventual synchronization
  • E-commerce catalogs - Product browsing works offline, checkout requires connectivity
  • Social feeds - Display cached posts while fetching updates in background
  • Field service apps - Technicians working in areas without reliable connectivity

Offline-first may be overkill for:

  • Real-time collaboration requiring constant connectivity
  • Video streaming or large media that can't be reasonably cached
  • Applications where data staleness creates serious problems

Service Worker Fundamentals

Service workers are the foundation of offline-first PWAs. Understanding their lifecycle, scope, and behavior is essential before implementing caching strategies.

What is a Service Worker?

A service worker is a JavaScript file that runs in the background, separate from your web page. It acts as a programmable network proxy, intercepting requests and determining how to respond—from cache, network, or custom logic.

Key characteristics:

  • Runs on a separate thread - Doesn't block the main UI thread
  • Event-driven - Responds to lifecycle events and fetch requests
  • HTTPS required - Security requirement (localhost exempt for development)
  • Cannot access DOM - Operates independently of web pages
  • Programmable cache - Controls what gets cached and when

Service Worker Lifecycle

Service workers progress through distinct lifecycle states:

1. Registration

Your application registers the service worker:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker
    .register('/sw.js')
    .then((registration) => {
      console.log('Service worker registered:', registration.scope);
    })
    .catch((error) => {
      console.error('Registration failed:', error);
    });
}

2. Installation

The service worker downloads and installs. This is where you cache initial assets:

// sw.js
const CACHE_NAME = 'app-cache-v1';
const STATIC_ASSETS = ['/', '/styles/main.css', '/scripts/app.js', '/images/logo.png'];

self.addEventListener('install', (event) => {
  event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)));
});

3. Activation

After installation, the service worker activates. Clean up old caches here:

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(cacheNames.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name)));
    }),
  );
});

4. Fetch Interception

The service worker intercepts network requests:

self.addEventListener('fetch', (event) => {
  event.respondWith(caches.match(event.request).then((response) => response || fetch(event.request)));
});

Service Worker Scope

The service worker scope determines which requests it can intercept. Scope is defined by the service worker file location:

  • Service worker at /sw.js controls scope /
  • Service worker at /app/sw.js controls scope /app/
  • Cannot control requests outside its scope

Best practice: Place service workers at the root (/sw.js) to control the entire origin.

Updating Service Workers

When you modify sw.js, browsers detect the change and install a new service worker version. The new worker waits until all pages using the old worker close before activating.

Skip waiting to activate immediately:

self.addEventListener('install', (event) => {
  self.skipWaiting(); // Activate immediately
});

self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim()); // Take control of pages immediately
});

Warning: Skip waiting can cause unexpected behavior if assets are incompatible between versions. Use carefully.

Core Caching Strategies

Service workers support multiple caching strategies, each optimizing for different content types and network conditions. Understanding when to use each strategy is critical for building effective offline-first PWAs.

1. Cache-First (Cache Falling Back to Network)

How it works:

  1. Check cache for matching request
  2. If found, return cached response immediately
  3. If not found, fetch from network
  4. Optionally cache the network response for future requests

Best for:

  • Static assets that rarely change (CSS, JavaScript, images)
  • Versioned resources (e.g., app.v2.js, logo.v3.png)
  • Font files
  • Third-party libraries

Implementation:

self.addEventListener('fetch', event => {
  const { request } = event;

  // Apply cache-first only to static assets
  if (request.destination === 'style' ||
      request.destination === 'script' ||
      request.destination === 'image') {

    event.respondWith(
      caches.match(request)
        .then(cached => {
          if (cached) {
            return cached;
          }

          return fetch(request).then(response => {
            // Cache successful responses
            if (response.ok) {
              const cache = await caches.open(CACHE_NAME);
              cache.put(request, response.clone());
            }
            return response;
          });
        })
    );
  }
});

Trade-offs:

  • Fastest performance - Instant response from cache
  • Fully offline-capable - Works without network
  • Stale content - Users might see outdated assets until cache updates
  • Storage overhead - All assets cached locally

2. Network-First (Network Falling Back to Cache)

How it works:

  1. Attempt to fetch from network
  2. If successful, return network response (optionally cache it)
  3. If network fails, fall back to cache
  4. If both fail, return error or offline page

Best for:

  • API responses requiring fresh data
  • User-generated content
  • Social feeds
  • Real-time data that tolerates graceful degradation

Implementation:

self.addEventListener('fetch', event => {
  const { request } = event;

  // Apply network-first to API requests
  if (request.url.includes('/api/')) {
    event.respondWith(
      fetch(request)
        .then(response => {
          // Cache successful API responses
          if (response.ok) {
            const cache = await caches.open(CACHE_NAME);
            cache.put(request, response.clone());
          }
          return response;
        })
        .catch(() => {
          // Network failed, try cache
          return caches.match(request)
            .then(cached => {
              if (cached) {
                return cached;
              }
              // Both failed, return offline response
              return new Response('Offline', {
                status: 503,
                statusText: 'Service Unavailable'
              });
            });
        })
    );
  }
});

Trade-offs:

  • Fresh content - Always attempts to get latest data
  • Graceful degradation - Falls back to cache when offline
  • Slower when online - Always makes network request first
  • Timeout delays - Network failures can take time to detect

Optimization: Add timeout to network requests:

const fetchWithTimeout = (request, timeout = 3000) => {
  return Promise.race([
    fetch(request),
    new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout)),
  ]);
};

self.addEventListener('fetch', (event) => {
  event.respondWith(fetchWithTimeout(event.request).catch(() => caches.match(event.request)));
});

3. Stale-While-Revalidate

How it works:

  1. Return cached response immediately (if available)
  2. Simultaneously fetch fresh data from network
  3. Update cache with fresh response for next request
  4. User sees stale content now, fresh content on next visit

Best for:

  • Content where immediate freshness isn't critical
  • Social media feeds
  • News articles
  • Product catalogs
  • Avatar images

Implementation:

self.addEventListener('fetch', event => {
  const { request } = event;

  event.respondWith(
    caches.match(request)
      .then(cached => {
        const fetchPromise = fetch(request)
          .then(response => {
            const cache = await caches.open(CACHE_NAME);
            cache.put(request, response.clone());
            return response;
          });

        // Return cached immediately, fetch in background
        return cached || fetchPromise;
      })
  );
});

Trade-offs:

  • Fast initial response - Instant from cache
  • Always updating - Cache stays fresh over time
  • Works offline - Serves stale content when network unavailable
  • Stale content first - Users see old data initially
  • Double bandwidth - Always fetching even if cached

4. Cache-Then-Network (Dual Requests)

How it works:

  1. Make two parallel requests: cache and network
  2. Return cached response immediately to UI
  3. When network response arrives, update UI and cache
  4. User sees instant response, then updated content

Best for:

  • Content that needs immediate display with updates
  • Stock prices, sports scores
  • Real-time data with fallbacks

Implementation:

This strategy requires coordination between page and service worker:

In your page:

// Request from both cache and network
const cached = await caches.match('/api/data');
const network = fetch('/api/data')
  .then((response) => response.json())
  .then((data) => {
    updateUI(data); // Update UI with fresh data
  });

if (cached) {
  const data = await cached.json();
  updateUI(data); // Display cached data immediately
}

In service worker:

self.addEventListener('fetch', event => {
  if (event.request.url.includes('/api/data')) {
    event.respondWith(
      fetch(event.request)
        .then(response => {
          const cache = await caches.open(CACHE_NAME);
          cache.put(event.request, response.clone());
          return response;
        })
    );
  }
});

Trade-offs:

  • Best of both worlds - Fast + fresh
  • Progressive enhancement - Shows data, then updates
  • Complex implementation - Requires page coordination
  • Double requests - Uses more bandwidth
  • UI flicker - Content updates can be jarring

5. Network-Only

How it works:

  1. Always fetch from network
  2. Never use cache
  3. Fail if network unavailable

Best for:

  • POST, PUT, DELETE requests
  • Authentication requests
  • Real-time data that can't be stale
  • Requests where caching breaks functionality

Implementation:

self.addEventListener('fetch', (event) => {
  // Never cache POST requests
  if (event.request.method !== 'GET') {
    event.respondWith(fetch(event.request));
    return;
  }

  // Apply other strategies for GET requests
});

Trade-offs:

  • Always fresh - No stale content
  • Simpler logic - No cache management
  • No offline support - Breaks without network
  • Slower - Network latency on every request

6. Cache-Only

How it works:

  1. Only return cached responses
  2. Never fetch from network
  3. Useful for precached app shell

Best for:

  • App shell architecture
  • Precached static assets you know exist
  • Offline pages

Implementation:

self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/app-shell/')) {
    event.respondWith(caches.match(event.request));
  }
});

Trade-offs:

  • Guaranteed instant - No network delay
  • Predictable - Always returns cached version
  • Must be precached - Fails if not in cache
  • Never updates - Requires service worker update to refresh

Choosing the Right Strategy

Different content types require different caching strategies. Here's a decision framework:

Content Type Strategy Rationale
HTML pages Network-first → Stale-while-revalidate Fresh content preferred, fallback to cache
CSS/JS bundles Cache-first (versioned URLs) Immutable when versioned, instant load
Images/fonts Cache-first Rarely change, large file sizes benefit from caching
API data Network-first or Stale-while-revalidate Depends on staleness tolerance
User avatars Stale-while-revalidate Can be slightly stale, updates in background
Real-time data Network-only Stale data is worse than no data
POST/PUT requests Network-only Mutations must reach server
App shell Cache-first or Cache-only Static shell should load instantly

Advanced Implementation Patterns

Pattern 1: Route-Based Strategy Selection

Apply different strategies based on URL patterns:

const CACHE_STRATEGIES = {
  cacheFirst: [
    /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
    /\.(?:woff|woff2|ttf|otf)$/,
    /\/static\//
  ],
  networkFirst: [
    /\/api\//,
    /\.html$/
  ],
  staleWhileRevalidate: [
    /\/images\/avatars\//,
    /\/feed\//
  ]
};

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

  // Determine strategy
  let strategy;
  if (CACHE_STRATEGIES.cacheFirst.some(pattern => pattern.test(url))) {
    strategy = cacheFirst;
  } else if (CACHE_STRATEGIES.networkFirst.some(pattern => pattern.test(url))) {
    strategy = networkFirst;
  } else if (CACHE_STRATEGIES.staleWhileRevalidate.some(pattern => pattern.test(url))) {
    strategy = staleWhileRevalidate;
  } else {
    strategy = networkOnly;
  }

  event.respondWith(strategy(request));
});

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

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

async function networkFirst(request) {
  try {
    const response = await fetch(request);
    if (response.ok) {
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, response.clone());
    }
    return response;
  } catch (error) {
    const cached = await caches.match(request);
    return cached || new Response('Offline', { status: 503 });
  }
}

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

  const fetchPromise = fetch(request)
    .then(response => {
      if (response.ok) {
        const cache = await caches.open(CACHE_NAME);
        cache.put(request, response.clone());
      }
      return response;
    });

  return cached || fetchPromise;
}

function networkOnly(request) {
  return fetch(request);
}

Pattern 2: Cache Expiration

Implement time-based cache expiration:

const CACHE_NAME = 'app-cache-v1';
const MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days

async function cacheWithExpiration(request, response) {
  const cache = await caches.open(CACHE_NAME);
  const headers = new Headers(response.headers);
  headers.set('sw-cached-at', Date.now().toString());

  const modifiedResponse = new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers,
  });

  cache.put(request, modifiedResponse);
}

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

  const cachedAt = cached.headers.get('sw-cached-at');
  if (!cachedAt) return cached;

  const age = Date.now() - parseInt(cachedAt);
  return age < MAX_AGE ? cached : null;
}

self.addEventListener('fetch', (event) => {
  event.respondWith(
    getCachedIfFresh(event.request).then((cached) => {
      if (cached) return cached;

      return fetch(event.request).then((response) => {
        cacheWithExpiration(event.request, response.clone());
        return response;
      });
    }),
  );
});

Pattern 3: Cache Size Limits

Prevent cache from growing unbounded:

const MAX_CACHE_SIZE = 50; // Maximum items per cache

async function trimCache(cacheName, maxSize) {
  const cache = await caches.open(cacheName);
  const keys = await cache.keys();

  if (keys.length > maxSize) {
    // Delete oldest entries (FIFO)
    const keysToDelete = keys.slice(0, keys.length - maxSize);
    await Promise.all(keysToDelete.map((key) => cache.delete(key)));
  }
}

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then(async (cached) => {
      const response = cached || (await fetch(event.request));

      if (!cached && response.ok) {
        const cache = await caches.open(CACHE_NAME);
        cache.put(event.request, response.clone());

        // Trim cache after adding
        trimCache(CACHE_NAME, MAX_CACHE_SIZE);
      }

      return response;
    }),
  );
});

Pattern 4: Offline Fallback Page

Provide a custom offline experience:

const OFFLINE_PAGE = '/offline.html';

// Cache offline page during installation
self.addEventListener('install', (event) => {
  event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.add(OFFLINE_PAGE)));
});

self.addEventListener('fetch', (event) => {
  // Only handle navigation requests
  if (event.request.mode === 'navigate') {
    event.respondWith(fetch(event.request).catch(() => caches.match(OFFLINE_PAGE)));
  }
});

Pattern 5: Background Sync

Queue failed requests for retry when connectivity returns:

// In your page
if ('serviceWorker' in navigator && 'sync' in registration) {
  navigator.serviceWorker.ready.then((registration) => {
    return registration.sync.register('sync-posts');
  });
}

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

async function syncPendingPosts() {
  const cache = await caches.open('pending-posts');
  const requests = await cache.keys();

  return Promise.all(
    requests.map(async (request) => {
      try {
        const response = await fetch(request);
        if (response.ok) {
          await cache.delete(request);
        }
      } catch (error) {
        // Will retry on next sync
        console.error('Sync failed:', error);
      }
    }),
  );
}

Cache Invalidation and Updates

Cache invalidation is notoriously difficult. Here are strategies for keeping caches fresh:

Version-Based Cache Invalidation

Update the cache name when deploying new code:

const CACHE_VERSION = 'v2';
const CACHE_NAME = `app-cache-${CACHE_VERSION}`;

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name.startsWith('app-cache-') && name !== CACHE_NAME)
          .map((name) => caches.delete(name)),
      );
    }),
  );
});

Selective Cache Invalidation

Delete specific cache entries based on patterns:

async function invalidateCachePattern(pattern) {
  const cacheNames = await caches.keys();

  return Promise.all(
    cacheNames.map(async (cacheName) => {
      const cache = await caches.open(cacheName);
      const keys = await cache.keys();

      const matchingKeys = keys.filter((request) => pattern.test(request.url));

      return Promise.all(matchingKeys.map((key) => cache.delete(key)));
    }),
  );
}

// Invalidate all API cache
self.addEventListener('message', (event) => {
  if (event.data.action === 'invalidate-api') {
    event.waitUntil(invalidateCachePattern(/\/api\//));
  }
});

Cache Busting with Query Parameters

Add timestamps or versions to URLs:

// In your app
const apiUrl = `/api/data?v=${Date.now()}`;
fetch(apiUrl);

Warning: This defeats caching entirely. Use sparingly.

HTTP Cache-Control Headers

Respect server cache directives:

self.addEventListener('fetch', event => {
  event.respondWith(
    fetch(event.request)
      .then(response => {
        const cacheControl = response.headers.get('Cache-Control');

        // Don't cache if server says no-cache
        if (cacheControl && cacheControl.includes('no-cache')) {
          return response;
        }

        // Cache otherwise
        const cache = await caches.open(CACHE_NAME);
        cache.put(event.request, response.clone());
        return response;
      })
  );
});

Testing and Debugging Offline Functionality

Chrome DevTools

1. Application Tab → Service Workers

  • View registered service workers
  • Force update/unregister
  • Simulate offline mode
  • Bypass service workers for development

2. Application Tab → Cache Storage

  • Inspect cached responses
  • Delete specific cache entries
  • View cache sizes
  • Verify cache contents

3. Network Tab

  • Filter by "Service Worker"
  • See which requests are served from cache
  • Inspect response headers

Testing Strategies

1. Offline Simulation

Test with DevTools offline mode:

// Detect offline/online
window.addEventListener('online', () => {
  console.log('Back online');
});

window.addEventListener('offline', () => {
  console.log('Lost connection');
});

2. Network Throttling

Test on slow connections (Slow 3G, Fast 3G) to verify performance benefits of caching.

3. Automated Testing

Use Puppeteer to test service worker behavior:

const puppeteer = require('puppeteer');

test('app works offline', async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();

  // Visit page while online
  await page.goto('https://example.com');

  // Go offline
  await page.setOfflineMode(true);

  // Navigate - should work from cache
  await page.goto('https://example.com/about');
  const content = await page.content();

  expect(content).toContain('About');

  await browser.close();
});

Common Issues and Solutions

Issue: Service worker not updating

Solution: Check cache-control headers on sw.js. Max-age should be 0:

Cache-Control: max-age=0

Issue: Old service worker stuck

Solution: Force update in DevTools or call:

navigator.serviceWorker.getRegistrations().then((registrations) => {
  registrations.forEach((reg) => reg.unregister());
});

Issue: Cache growing too large

Solution: Implement cache size limits (see Pattern 3 above).

Issue: Stale content persisting

Solution: Implement cache expiration or version-based invalidation.

Production Best Practices

1. Use Workbox

Workbox is Google's library for production service workers:

importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.5.4/workbox-sw.js');

workbox.routing.registerRoute(
  ({ request }) => request.destination === 'image',
  new workbox.strategies.CacheFirst({
    cacheName: 'images',
    plugins: [
      new workbox.expiration.ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
      }),
    ],
  }),
);

workbox.routing.registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new workbox.strategies.NetworkFirst({
    cacheName: 'api',
    networkTimeoutSeconds: 3,
  }),
);

Benefits:

  • Battle-tested caching strategies
  • Built-in cache expiration
  • Precaching support
  • Background sync helpers

2. Monitor Cache Performance

Track cache hit rates and performance:

self.addEventListener('fetch', (event) => {
  const start = Date.now();

  event.respondWith(
    caches.match(event.request).then((cached) => {
      if (cached) {
        const duration = Date.now() - start;
        logMetric('cache-hit', duration);
        return cached;
      }

      return fetch(event.request).then((response) => {
        const duration = Date.now() - start;
        logMetric('cache-miss', duration);
        return response;
      });
    }),
  );
});

function logMetric(type, duration) {
  // Send to analytics
  self.clients.matchAll().then((clients) => {
    clients.forEach((client) => {
      client.postMessage({
        type: 'metric',
        data: { type, duration },
      });
    });
  });
}

3. Handle Different Content Types

Apply appropriate strategies per content type:

const strategyMap = new Map([
  ['document', networkFirst],
  ['style', cacheFirst],
  ['script', cacheFirst],
  ['image', cacheFirst],
  ['font', cacheFirst],
]);

self.addEventListener('fetch', (event) => {
  const strategy = strategyMap.get(event.request.destination) || networkFirst;
  event.respondWith(strategy(event.request));
});

4. Provide User Controls

Let users manage cache:

// In your app
async function clearAppCache() {
  const cacheNames = await caches.keys();
  await Promise.all(cacheNames.map((name) => caches.delete(name)));

  // Unregister service worker
  const registration = await navigator.serviceWorker.getRegistration();
  if (registration) {
    await registration.unregister();
  }

  window.location.reload();
}

// Add to settings page
<button onclick="clearAppCache()">Clear Cache & Reload</button>;

5. Set Storage Quotas

Monitor storage usage:

if ('storage' in navigator && 'estimate' in navigator.storage) {
  navigator.storage.estimate().then(({ usage, quota }) => {
    const percentUsed = ((usage / quota) * 100).toFixed(2);
    console.log(`Using ${percentUsed}% of storage quota`);
    console.log(`${usage} bytes of ${quota} bytes used`);
  });
}

Request persistent storage for critical apps:

if ('storage' in navigator && 'persist' in navigator.storage) {
  navigator.storage.persist().then((persistent) => {
    console.log('Persistent storage:', persistent);
  });
}

Integration with Other PWA Features

Offline-first architecture works in concert with other PWA capabilities to create resilient applications.

Push Notifications

Service workers power both caching and push notifications. The same service worker file can handle both:

// Handle push notifications
self.addEventListener('push', (event) => {
  const data = event.data.json();

  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/images/icon.png',
    }),
  );
});

// Handle fetch for caching
self.addEventListener('fetch', (event) => {
  event.respondWith(handleFetch(event.request));
});

For a comprehensive guide to implementing push notifications in PWAs, see Using Push Notifications in PWAs.

Background Sync

Queue failed requests for automatic retry:

// Queue request when offline
self.addEventListener('fetch', (event) => {
  if (event.request.method === 'POST') {
    event.respondWith(
      fetch(event.request.clone()).catch(async () => {
        // Store for background sync
        const cache = await caches.open('sync-queue');
        await cache.put(event.request, new Response());

        // Register sync
        await self.registration.sync.register('sync-posts');

        return new Response('Queued for sync', { status: 202 });
      }),
    );
  }
});

IndexedDB for Complex Data

For structured data beyond simple request/response caching:

// Store structured data
const db = await openDB('app-db', 1, {
  upgrade(db) {
    db.createObjectStore('posts', { keyPath: 'id' });
  },
});

await db.add('posts', {
  id: 1,
  title: 'Post Title',
  content: 'Post content...',
});

// Retrieve later
const posts = await db.getAll('posts');

Platform-Specific Considerations

iOS Safari Limitations

Safari has stricter service worker limitations:

  • 7-day cache expiration - iOS automatically purges service worker caches after 7 days of inactivity
  • Storage quotas - More aggressive eviction policies than Chrome
  • Background sync - Not supported
  • Push notifications - Requires iOS 16.4+ and home screen installation

Mitigation strategies:

  • Prioritize most critical assets within storage limits
  • Re-cache on app launch
  • Educate users about home screen installation for full functionality

For detailed iOS PWA considerations, see Best Practices for iOS PWA Push Notifications and Essential PWA Strategies for Enhanced iOS Performance.

Android Chrome

Chrome on Android provides the best PWA support:

  • Automatic install prompts based on engagement
  • Full service worker capabilities
  • Generous storage quotas
  • Background sync support

Desktop Browsers

Desktop Chrome, Edge, and Firefox offer excellent PWA support with generous storage and full service worker features.

Choosing Between PWAs and Native Apps

When evaluating whether offline-first PWAs meet your needs, consider the comparison framework in PWA vs Native App: When to Build an Installable Progressive Web App. This guide helps teams decide when PWA offline capabilities are sufficient versus when native development is required.

Conclusion

Offline-first architecture powered by service worker caching transforms progressive web apps from fragile web pages into resilient applications that work reliably across varying network conditions.

Key takeaways:

  1. Choose strategies per content type - Static assets benefit from cache-first, dynamic content from network-first or stale-while-revalidate
  2. Test offline thoroughly - Use DevTools, network throttling, and automated tests
  3. Implement cache invalidation - Version-based or selective invalidation prevents stale content
  4. Monitor performance - Track cache hit rates and storage usage
  5. Consider platform limitations - iOS Safari has stricter constraints than Chrome

By implementing appropriate caching strategies and following production best practices, you can build PWAs that deliver fast, reliable experiences regardless of network connectivity—bridging the gap between web and native apps.

For teams building comprehensive PWAs, combine offline-first caching with push notifications to create engaging, resilient applications that rival native app experiences.

Frequently Asked Questions

How much storage can service workers use?

Storage limits vary by browser and available disk space. Chrome typically allows 60% of available disk space, with a minimum of several hundred MB. Use the Storage API to check quotas:

navigator.storage.estimate().then(({ usage, quota }) => {
  console.log(`${usage} of ${quota} bytes used`);
});

Can service workers cache POST requests?

Service workers can intercept POST requests but shouldn't cache them. POST requests typically involve mutations (creating/updating data) that shouldn't be replayed from cache. Use network-only strategy for POST/PUT/DELETE requests.

How do I debug service worker caching issues?

Use Chrome DevTools:

  1. Application tab → Service Workers (update/unregister)
  2. Application tab → Cache Storage (inspect cache contents)
  3. Network tab → Filter by "ServiceWorker" (see which requests are cached)
  4. Console → Log cache operations in service worker code

What happens when cache storage is full?

Browsers automatically evict cached data when storage is full, typically using LRU (Least Recently Used) eviction. Implement cache size limits and expiration to control cache growth before browser eviction occurs.

Should I cache third-party resources?

Yes, but carefully. Cache third-party assets (fonts, libraries) using cache-first for performance. However, ensure you respect cache-control headers and licensing. Some CDNs explicitly prohibit caching.

How do I update cached content?

Common strategies:

  • Version-based cache names (update cache name in new service worker)
  • Time-based expiration (delete old cache entries periodically)
  • Stale-while-revalidate (update in background)
  • Force update via messaging (user-initiated cache clear)

Can I use service workers without HTTPS?

Only on localhost for development. Production requires HTTPS because service workers have powerful capabilities that must be secured. Use Let's Encrypt for free SSL certificates.

How do offline-first PWAs compare to native apps?

Offline-first PWAs approach native app capabilities for content-heavy applications but have limitations for hardware access and background processing. For a detailed comparison, see PWA vs Native App: When to Build an Installable Progressive Web App.