Offline-First PWAs: Service Worker Caching Strategies

featured article thumbnail

Offline-first PWAs are progressive web apps built to work without an internet connection. They use service workers to cache key assets and data so users can access content during network failures or in areas with poor connectivity. Caching strategies like cache-first, network-first, and stale-while-revalidate control how a PWA handles requests. Each one balances freshness and speed differently.

Building offline apps used to mean native development with complex sync logic. Progressive web apps changed that. Service workers act as network proxies that sit between your app and the internet. They intercept requests and decide how to respond — from cache, from the network, or both.

This guide covers offline-first architecture, service worker basics, and the caching strategies that make PWAs work without a network. You will learn how to pick the right strategy, write the code, and debug issues in production.

What is Offline-First Architecture?

Offline-first is a design approach that treats the network as unreliable. Instead of showing error screens when the connection drops, your app keeps working with cached data. The network is a nice-to-have, not a requirement.

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 shift turns PWAs from fragile web pages into apps that work no matter what the network looks like.

Benefits of Offline-First PWAs

1. Resilience During Network Failures

Users never see "You are offline" error screens. The app keeps working with cached data instead of breaking completely.

2. Improved Performance

Serving assets from cache is much 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 means fewer server requests and less bandwidth. For apps with millions of users, this saves real money on infrastructure.

4. Emerging Market Accessibility

In regions with spotty connectivity or expensive data plans, offline-first PWAs let people use apps they otherwise could not access.

5. Better User Experience

Users expect apps to just work. Offline-first PWAs deliver on that by making online and offline feel the same.

When Offline-First Makes Sense

Offline-first architecture is ideal for:

  • Content apps — News sites, docs, and blogs where content can be stored locally
  • Productivity tools — Note apps, task managers, and calendars that sync later
  • E-commerce — Product browsing works offline, checkout needs a connection
  • Social feeds — Show cached posts while fetching new ones in the background
  • Field service apps — Workers in areas without reliable internet

You probably do not need offline-first for:

  • Real-time collaboration that needs constant connectivity
  • Video streaming or large media files
  • Apps where stale data causes real problems

Service Worker Fundamentals

Service workers are the foundation of offline-first PWAs. You need to understand how they work before picking a caching strategy.

What is a Service Worker?

A service worker is a JavaScript file that runs in the background, separate from your web page. It sits between your app and the network. When your app makes a request, the service worker decides what to do — serve from cache, fetch from the network, or run custom logic.

Key characteristics:

  • Runs on its own thread — It will not block your UI
  • Event-driven — It reacts to fetch events and lifecycle events
  • HTTPS only — Required for security (localhost is exempt)
  • No DOM access — It cannot touch the page directly
  • Full cache control — You decide what to cache and when

Service Worker Lifecycle

Service workers go through four stages:

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 scope controls which requests a service worker can intercept. It is set by the 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 change sw.js, the browser installs a new version. The new worker waits until all tabs using the old one close before it takes over.

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 break things if old pages expect old assets. Use with care.

Core Caching Strategies

Each caching strategy makes a different trade-off between speed and freshness. The right choice depends on your content type.

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

Here is a quick reference for picking the right strategy:

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 one of the hardest problems in computer science. Here are proven ways to keep 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 caching works alongside other PWA features. You will also need a web app manifest — use our PWA manifest generator to create one.

Push Notifications

The same service worker file can handle both caching and push notifications:

// 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 the full setup guide, 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 tighter service worker restrictions:

  • 7-day cache expiration — iOS deletes service worker caches after 7 days without use
  • Storage quotas — iOS evicts cached data more aggressively than Chrome
  • Background sync — Not supported on iOS
  • Push notifications — Requires iOS 16.4+ and home screen install

Mitigation strategies:

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

For more details, see Essential PWA Strategies for iOS and our complete guide to PWA iOS limitations.

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

Chrome, Edge, and Firefox on desktop all offer full service worker support with generous storage limits.

Choosing Between PWAs and Native Apps

Not sure if a PWA is enough for your use case? Our PWA vs native app guide helps you decide when PWA offline support is enough and when you need native development.

Conclusion

Service worker caching turns PWAs from fragile web pages into apps that work regardless of network conditions.

Key takeaways:

  1. Pick strategies by content type — Use cache-first for static assets and network-first for dynamic data.
  2. Test offline early and often — Use DevTools, throttling, and automated tests.
  3. Plan for cache invalidation — Use versioned cache names or time-based expiration.
  4. Monitor cache performance — Track hit rates and storage usage.
  5. Know the platform gaps — iOS Safari has tighter limits than Chrome.

With the right caching strategy, your PWA will load fast and work offline. Combine that with push notifications and you have a web app that rivals a native one.

Frequently Asked Questions

How much storage can service workers use?

It depends on the browser and your device's free disk space. Chrome allows up to 60% of available space. Use the Storage API to check:

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 you should not cache them. POST requests create or change data. Replaying them from cache would cause duplicate actions. Use the network-only strategy for POST, PUT, and 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 delete old cached data when storage fills up. They use LRU (Least Recently Used) eviction. Set your own cache size limits and expiration rules so you control what gets removed — not the browser.

Should I cache third-party resources?

Yes, but be careful. Cache fonts and libraries with cache-first for speed. But check cache-control headers and license terms first. Some CDNs do not allow 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. In production, you must use HTTPS. Service workers can intercept all network traffic, so browsers require encryption. Use Let's Encrypt for free SSL certificates.

How do offline-first PWAs compare to native apps?

For content-heavy apps, offline-first PWAs come close to native. But they still fall short on hardware access and background tasks. See our PWA vs native app comparison for a full breakdown.

Ready to Ship?

Add Web Push to Your App

MagicBell handles service workers, VAPID keys, and cross-browser compatibility so you can focus on building your product.

Web Push Notification Service