Back to Blog

Service Workers for Performance: Build Lightning-Fast Progressive Web Apps

Master Service Workers to create blazing-fast web applications. Learn caching strategies, offline functionality, background sync, and performance optimization techniques for modern PWAs.

13 min read
By Perf Lens Team

Introduction

Service Workers are a game-changer for web performance. They act as a programmable network proxy between your web app and the network, enabling features like:

  • Instant page loads (cache-first strategies)
  • 🔄 Offline functionality (work without internet)
  • 🚀 Background sync (sync data when connection returns)
  • 📱 Progressive Web Apps (app-like experience)
  • 💾 Reduced bandwidth (serve from cache)

According to Google, apps using Service Workers load 3x faster on repeat visits and can work completely offline.

In this comprehensive guide, we'll:

  • Understand how Service Workers work
  • Implement caching strategies for performance
  • Build offline-first applications
  • Optimize cache management
  • Measure performance improvements

By the end of this article, you'll know how to leverage Service Workers to build lightning-fast web applications.


What Are Service Workers?

Definition

A Service Worker is a JavaScript file that runs in the background, separate from your web page. It acts as a proxy between your app and the network.

Key Characteristics:

  • ✅ Runs in a separate thread (doesn't block UI)
  • ✅ Can intercept network requests
  • ✅ Has access to Cache API
  • ✅ Can receive push notifications
  • ✅ Enables offline functionality
  • ❌ Cannot access DOM directly
  • ❌ Requires HTTPS (security)

Service Worker Lifecycle

Registration → Installation → Activation → Idle → Fetch/Message → Termination
     ↓              ↓              ↓
  navigator.   install event   activate event
  serviceWorker
  .register()

Lifecycle Phases:

  • 1. Registration**: Register the service worker file
  • 2. Installation**: Download and install the service worker
  • 3. Activation**: Service worker takes control
  • 4. Idle**: Waiting for events (fetch, push, sync)
  • 5. Termination**: Browser stops the worker to save memory

Basic Service Worker Setup

Step 1: Register the Service Worker

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

Step 2: Install and Cache Resources

// sw.js (service worker file)
const CACHE_NAME = 'my-app-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles.css',
  '/script.js',
  '/logo.png'
];

// Install event - cache static assets
self.addEventListener('install', (event) => {
  console.log('Service Worker installing...');

  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => {
        console.log('Caching static assets');
        return cache.addAll(STATIC_ASSETS);
      })
      .then(() => self.skipWaiting()) // Activate immediately
  );
});

Step 3: Activate and Clean Old Caches

// sw.js
self.addEventListener('activate', (event) => {
  console.log('Service Worker activating...');

  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames
          .filter(name => name !== CACHE_NAME)
          .map(name => {
            console.log('Deleting old cache:', name);
            return caches.delete(name);
          })
      );
    })
    .then(() => self.clients.claim()) // Take control immediately
  );
});

Step 4: Intercept Network Requests

// sw.js
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then(cachedResponse => {
        // Return cached response if found
        if (cachedResponse) {
          return cachedResponse;
        }

        // Otherwise, fetch from network
        return fetch(event.request);
      })
  );
});

Caching Strategies

Different strategies optimize for different use cases.

Strategy 1: Cache First (Fastest)

Use Case: Static assets that rarely change (images, CSS, JS)

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

Performance:

  • First Load: 1500ms (network)
  • Repeat Load: 50ms (cache) ✅ 30x faster!

Strategy 2: Network First (Fresh Data)

Use Case: API requests that need fresh data

self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then(response => {
        // Cache successful response
        if (response.status === 200) {
          const clonedResponse = response.clone();
          caches.open(CACHE_NAME).then(cache => {
            cache.put(event.request, clonedResponse);
          });
        }
        return response;
      })
      .catch(() => {
        // Fallback to cache on network failure
        return caches.match(event.request);
      })
  );
});

Performance:

  • Online: Network speed (fresh data)
  • Offline: Instant (cached fallback) ✅

Strategy 3: Stale-While-Revalidate (Best of Both)

Use Case: Balance between speed and freshness

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).then(cache => {
      return cache.match(event.request).then(cachedResponse => {
        const fetchPromise = fetch(event.request).then(networkResponse => {
          // Update cache with fresh response
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });

        // Return cached response immediately, update in background
        return cachedResponse || fetchPromise;
      });
    })
  );
});

Performance:

  • First Load: Cache hit (instant) + background update
  • Always shows content immediately ✅

Strategy 4: Cache Only (Offline-First)

Use Case: App shell, critical assets

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then(cachedResponse => {
        if (!cachedResponse) {
          throw new Error('No cached response');
        }
        return cachedResponse;
      })
  );
});

Strategy 5: Network Only (Always Fresh)

Use Case: Sensitive data, analytics

self.addEventListener('fetch', (event) => {
  // Don't cache, always fetch from network
  event.respondWith(fetch(event.request));
});

Advanced Caching Patterns

Pattern 1: Cache with Network Fallback + Timeout

async function fetchWithTimeout(request, timeout = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);

  try {
    const response = await fetch(request, {
      signal: controller.signal
    });
    clearTimeout(timeoutId);
    return response;
  } catch (error) {
    clearTimeout(timeoutId);
    throw error;
  }
}

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then(cachedResponse => {
        if (cachedResponse) {
          return cachedResponse;
        }

        return fetchWithTimeout(event.request, 3000)
          .catch(() => {
            // Return offline page on timeout/error
            return caches.match('/offline.html');
          });
      })
  );
});

Pattern 2: Different Strategies per Resource Type

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;
  }

  // Images: Cache first
  if (event.request.destination === 'image') {
    event.respondWith(cacheFirst(event.request));
    return;
  }

  // HTML: Stale-while-revalidate
  if (event.request.destination === 'document') {
    event.respondWith(staleWhileRevalidate(event.request));
    return;
  }

  // Default: Network first
  event.respondWith(networkFirst(event.request));
});

async function cacheFirst(request) {
  const cached = await caches.match(request);
  return cached || fetch(request);
}

async function networkFirst(request) {
  try {
    const response = await fetch(request);
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
    return response;
  } catch (error) {
    return caches.match(request);
  }
}

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

  const fetchPromise = fetch(request).then(response => {
    cache.put(request, response.clone());
    return response;
  });

  return cached || fetchPromise;
}

Cache Management

Strategy 1: Limit Cache Size

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

  if (keys.length > maxItems) {
    // Delete oldest entries
    await cache.delete(keys[0]);
    await limitCacheSize(cacheName, maxItems);
  }
}

// Call after adding to cache
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request).then(response => {
      const cache = caches.open('images-cache');
      cache.then(c => {
        c.put(event.request, response.clone());
        limitCacheSize('images-cache', 50); // Max 50 images
      });
      return response;
    })
  );
});

Strategy 2: Cache Expiration

const MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days

async function isCacheExpired(response) {
  const cachedDate = response.headers.get('sw-cache-date');
  if (!cachedDate) return true;

  const age = Date.now() - new Date(cachedDate).getTime();
  return age > MAX_AGE;
}

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then(async (cachedResponse) => {
      if (cachedResponse && !(await isCacheExpired(cachedResponse))) {
        return cachedResponse;
      }

      const response = await fetch(event.request);
      const clonedResponse = response.clone();

      // Add cache date header
      const headers = new Headers(response.headers);
      headers.set('sw-cache-date', new Date().toISOString());

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

      const cache = await caches.open(CACHE_NAME);
      cache.put(event.request, cachedResponse);

      return response;
    })
  );
});

Background Sync

Defer actions until connectivity is restored.

Basic Background Sync

// In your app
async function saveData(data) {
  try {
    await fetch('/api/save', {
      method: 'POST',
      body: JSON.stringify(data)
    });
  } catch (error) {
    // Register sync event for later
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('save-data');

    // Store data in IndexedDB for later
    await saveToIndexedDB(data);
  }
}
// In service worker
self.addEventListener('sync', (event) => {
  if (event.tag === 'save-data') {
    event.waitUntil(
      getFromIndexedDB().then(data => {
        return fetch('/api/save', {
          method: 'POST',
          body: JSON.stringify(data)
        }).then(() => {
          // Clear IndexedDB after successful sync
          return clearIndexedDB();
        });
      })
    );
  }
});

Performance Optimization Techniques

Technique 1: Precaching Critical Assets

const CRITICAL_ASSETS = [
  '/',
  '/app-shell.html',
  '/critical.css',
  '/app.js'
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('critical-v1')
      .then(cache => cache.addAll(CRITICAL_ASSETS))
      .then(() => self.skipWaiting())
  );
});

Result: Instant page loads on repeat visits!


Technique 2: Lazy-Loading Non-Critical Assets

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  // Only cache navigation requests
  if (event.request.mode === 'navigate') {
    event.respondWith(
      caches.match(event.request).then(response => {
        if (response) {
          // Lazy-load images in background
          event.waitUntil(prefetchImages());
          return response;
        }
        return fetch(event.request);
      })
    );
  }
});

async function prefetchImages() {
  const images = ['/hero.jpg', '/logo.png'];
  const cache = await caches.open('images-v1');

  return Promise.all(
    images.map(url =>
      fetch(url).then(response => cache.put(url, response))
    )
  );
}

Technique 3: Compression with Service Workers

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  // Request Brotli-compressed version if supported
  if (event.request.headers.get('Accept-Encoding').includes('br')) {
    const brotliRequest = new Request(`${url.pathname}.br`, {
      headers: event.request.headers
    });

    event.respondWith(
      fetch(brotliRequest)
        .then(response => {
          // Decompress and cache
          return response;
        })
        .catch(() => fetch(event.request)) // Fallback to original
    );
  }
});

Real-World Example: News App

Complete implementation of a news app with offline support.

// sw.js
const APP_SHELL_CACHE = 'app-shell-v1';
const CONTENT_CACHE = 'content-v1';
const IMAGE_CACHE = 'images-v1';

const APP_SHELL_ASSETS = [
  '/',
  '/index.html',
  '/app.js',
  '/styles.css',
  '/offline.html'
];

// Install - cache app shell
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(APP_SHELL_CACHE)
      .then(cache => cache.addAll(APP_SHELL_ASSETS))
      .then(() => self.skipWaiting())
  );
});

// Activate - clean old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(keys => {
      return Promise.all(
        keys
          .filter(key => key !== APP_SHELL_CACHE &&
                        key !== CONTENT_CACHE &&
                        key !== IMAGE_CACHE)
          .map(key => caches.delete(key))
      );
    }).then(() => self.clients.claim())
  );
});

// Fetch - different strategies per type
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  // App shell: Cache first
  if (APP_SHELL_ASSETS.includes(url.pathname)) {
    event.respondWith(cacheFirst(APP_SHELL_CACHE, event.request));
    return;
  }

  // API: Network first with cache fallback
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(CONTENT_CACHE, event.request));
    return;
  }

  // Images: Cache first with lazy loading
  if (event.request.destination === 'image') {
    event.respondWith(cacheFirst(IMAGE_CACHE, event.request, {
      maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
      maxItems: 100
    }));
    return;
  }

  // Default: Network only
  event.respondWith(fetch(event.request));
});

// Helper functions
async function cacheFirst(cacheName, request, options = {}) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);

  if (cached) {
    // Check expiration if maxAge specified
    if (options.maxAge) {
      const cachedDate = cached.headers.get('sw-cache-date');
      if (cachedDate) {
        const age = Date.now() - new Date(cachedDate).getTime();
        if (age < options.maxAge) {
          return cached;
        }
      }
    } else {
      return cached;
    }
  }

  try {
    const response = await fetch(request);

    if (response.status === 200) {
      const clonedResponse = response.clone();
      const headers = new Headers(response.headers);
      headers.set('sw-cache-date', new Date().toISOString());

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

      await cache.put(request, cachedResponse);

      // Limit cache size if specified
      if (options.maxItems) {
        limitCacheSize(cacheName, options.maxItems);
      }
    }

    return response;
  } catch (error) {
    return cached || caches.match('/offline.html');
  }
}

async function networkFirst(cacheName, request) {
  try {
    const response = await fetch(request);

    if (response.status === 200) {
      const cache = await caches.open(cacheName);
      cache.put(request, response.clone());
    }

    return response;
  } catch (error) {
    const cached = await caches.match(request);
    return cached || caches.match('/offline.html');
  }
}

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

  if (keys.length > maxItems) {
    await cache.delete(keys[0]);
    await limitCacheSize(cacheName, maxItems);
  }
}

Performance Results:

  • First Load: 2.5s (network)
  • Repeat Load: 0.3s (cache) ✅ 8x faster
  • Offline: Works perfectly

Debugging Service Workers

Chrome DevTools

  1. Open DevTools → Application tab
  2. Click Service Workers in left sidebar
  3. View registered workers, update, unregister

Key Features:

  • ✅ View cache storage
  • ✅ Simulate offline mode
  • ✅ Force update service worker
  • ✅ Unregister for testing

Console Logging

// sw.js
self.addEventListener('install', (event) => {
  console.log('[SW] Installing...');
});

self.addEventListener('activate', (event) => {
  console.log('[SW] Activated');
});

self.addEventListener('fetch', (event) => {
  console.log('[SW] Fetching:', event.request.url);
});

Performance Benchmarks

Benchmark 1: Repeat Page Loads

Setup: News website with 50 articles

MetricWithout SWWith SWImprovement
Load Time3.2s0.4s88% faster
Requests45295% fewer
Data Transfer2.5 MB50 KB98% less

Benchmark 2: Offline Capability

Setup: E-commerce product page

FeatureWithout SWWith SW
Offline Access❌ Error page✅ Full page
Product Images❌ Not loaded✅ Cached
Add to Cart❌ Fails✅ Queued

Best Practices

✅ DO:

  • Cache app shell for instant loads
  • Use appropriate caching strategy per resource
  • Version your caches for easy updates
  • Clean up old caches on activation
  • Set cache expiration policies
  • Limit cache size to prevent bloat
  • Test offline functionality thoroughly

❌ DON'T:

  • Cache everything (be selective)
  • Cache sensitive data (authentication tokens)
  • Forget to handle cache updates
  • Ignore cache storage limits (50-250 MB browser limit)
  • Cache without expiration strategy
  • Block installation on cache failures

Common Pitfalls

Pitfall 1: Not Updating Service Worker

// ❌ Bad: Service worker never updates
const CACHE_NAME = 'my-app-v1'; // Never changes!

// ✅ Good: Update cache version to force update
const CACHE_NAME = 'my-app-v2'; // Incremented version

Pitfall 2: Caching API Responses Indefinitely

// ❌ Bad: Stale API data forever
event.respondWith(
  caches.match(event.request) || fetch(event.request)
);

// ✅ Good: Network first for API, with expiration
if (url.pathname.startsWith('/api/')) {
  event.respondWith(networkFirst(event.request));
}

Pitfall 3: Not Handling Failed Installations

// ❌ Bad: Silent failure
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => cache.addAll(ASSETS))
  );
});

// ✅ Good: Graceful degradation
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(ASSETS))
      .catch(error => {
        console.error('[SW] Install failed:', error);
        // Continue without caching critical assets
      })
  );
});

Conclusion

Service Workers are essential for building high-performance, reliable web applications. They enable instant page loads, offline functionality, and app-like experiences.

Key Takeaways:

  • 1. Caching Strategies** matter - choose the right one per resource
  • 2. App Shell Architecture** provides instant loads
  • 3. Cache Management** prevents bloat and stale content
  • 4. Background Sync** enables offline-first apps
  • 5. Performance Impact** can be 80-95% faster repeat visits

Performance Gains:

  • Repeat Loads: 3-10x faster
  • Data Transfer: 90-98% reduction
  • Offline: Full app functionality
  • Reliability: Works in poor network conditions

Implementation Checklist:

  • Register service worker
  • Cache app shell during installation
  • Implement appropriate caching strategies
  • Add cache versioning and cleanup
  • Test offline functionality
  • Monitor cache sizes
  • Implement background sync (if needed)

Remember: Service Workers are a progressive enhancement. Apps should work without them, but be dramatically better with them!


Further Reading


Want to measure the performance impact of your optimizations? Use Perf Lens to benchmark before and after Service Worker implementation!

Service Workers for Performance: Build Lightning-Fast Progressive Web Apps | Perf Lens