Learning Progressive Web Apps for Offline Functionality

Introduction

Throughout my 12-year career as a Ruby on Rails Architect, the single biggest challenge I've encountered with web applications is ensuring seamless offline functionality. Many mobile users abandon websites that take longer than three seconds to load, emphasizing the need for robust offline capabilities in Progressive Web Apps (PWAs).

This tutorial will guide you through the essential steps to build a Progressive Web App that can function offline. You'll learn to implement service workers, which act as a proxy between your web application and the network, enabling caching of assets and handling of network requests.

By following the examples and best practices here you can create an engaging user experience that remains accessible regardless of connectivity. This includes practical patterns for caching, IndexedDB usage for structured offline storage, and Workbox integration to reduce boilerplate and maintenance burden.

Understanding Service Workers: The Backbone of Offline Functionality

What Are Service Workers?

Service workers are scripts that run in the background, separate from the web page. They enable features like push notifications and background sync, and — most importantly for offline-first PWAs — they intercept network requests to serve cached responses or fall back to the network.

In one travel booking project, we cached critical CSS and JS bundles (for example /assets/app.css and /assets/vendor.js) using a cache-first strategy; on constrained networks this reduced initial render latency substantially for first-time navigation to the main listing page.

The lifecycle of a service worker includes several states: installing, waiting, and active. Understanding these states is key to managing updates effectively. For example, use the activate event to remove old caches (see the activate example later) to avoid serving mixed-version assets.

  • Run in the background
  • Control network requests
  • Enable offline capabilities
  • Manage caching strategies

Example: a robust fetch handler that implements a stale-while-revalidate pattern and updates the cache safely.


self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(cachedResponse => {
      if (cachedResponse) {
        // Serve cached response immediately and revalidate in the background
        fetch(event.request).then(networkResponse => {
          if (networkResponse && networkResponse.ok) {
            caches.open('v1').then(cache => cache.put(event.request, networkResponse.clone()));
          }
        }).catch(() => {});
        return cachedResponse;
      }

      // No cache, try network and populate cache
      return fetch(event.request).then(networkResponse => {
        if (!networkResponse || !networkResponse.ok) return networkResponse;
        return caches.open('v1').then(cache => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
      }).catch(() => {
        // Optional: return a generic fallback for navigation requests
        return caches.match('/offline.html');
      });
    })
  );
});

Notes on the example above:

  • Always check networkResponse.ok before caching to avoid storing 4xx/5xx responses.
  • Clone responses before caching because Response objects are streams and are consumed once.
  • Consider a dedicated cache for navigational fallbacks (e.g., /offline.html).
Service Worker Request Flow Diagram showing the browser request flow intercepted by a service worker with cache and network fallback. Client Browser UI Browser Request Handling Service Worker Intercepts & Caches Cache Cached Assets Network Origin Server
Figure: Detailed Service Worker Request Flow — Client initiates request; Browser forwards to Service Worker, which checks Cache first and then Network; responses are returned to Client. Cache updates can happen asynchronously (stale-while-revalidate), and a navigational fallback (e.g., /offline.html) is used when both sources fail.

Caching Strategies for Optimal Offline Performance

Effective Caching Techniques

Caching strategies are vital for ensuring a seamless offline experience. One common method is the Cache First strategy, where the app loads resources from the cache before checking the network. In an event ticketing app I worked on, caching static bundles and critical UI assets helped pages render instantly while network requests updated in the background.

Another effective strategy is the Network First approach, where the app attempts to fetch the latest data first. If the network is unavailable, it falls back to cached data. For example, in a weather app we used Network First for forecast JSON endpoints and Cache First for static UI assets.

Stale While Revalidate: This strategy serves cached content immediately while simultaneously checking the network for updates, then updates the cache for future requests. It's ideal for content that needs to be fresh but can tolerate showing slightly older data initially (e.g., news feeds, dashboards).

  • Cache First for static assets
  • Network First for dynamic content
  • Stale While Revalidate for fresh data
  • Cache with versioning for updates

Example: precaching essential files during install (version your cache name to force updates).


caches.open('v1').then(cache => {
  return cache.addAll(['/index.html', '/styles.css', '/assets/app.css', '/assets/vendor.js']);
});

Notes:

  • Version cache names (e.g., v1, v2) so you can delete old caches during activate.
  • Avoid caching large blobs unless necessary; prefer streaming or range requests for heavy media.

Implementing Offline Functionality: Step-by-Step Guide

Setting Up Your Service Worker

Service workers require secure contexts (HTTPS) except on localhost. Register the worker from your main script and handle install/activate lifecycle events to populate and prune caches.


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

Implement the install event to cache resources. Use self.skipWaiting() judiciously and coordinate activation with clients when you need an immediate update.


self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('v1').then(cache =>
      cache.addAll(['/index.html', '/styles.css', '/assets/app.css', '/assets/vendor.js'])
    ).then(() => self.skipWaiting())
  );
});

In the activate event, remove old caches to avoid storage bloat and ensure users receive updated assets.


self.addEventListener('activate', (event) => {
  const expectedCaches = ['v1'];
  event.waitUntil(
    caches.keys().then(keys => Promise.all(
      keys.map(key => {
        if (!expectedCaches.includes(key)) return caches.delete(key);
        return Promise.resolve();
      })
    )).then(() => self.clients.claim())
  );
});

Security and correctness tips:

  • Only cache responses with networkResponse.ok to avoid storing error pages.
  • Validate CORS headers for cross-origin resources before caching.
  • Limit cache size (evict LRU entries or use expiration libraries) to prevent storage exhaustion.
  • Use HTTPS to enable service workers in production; test on localhost for development.

IndexedDB Example for Offline Storage

Why IndexedDB?

IndexedDB is the browser-native solution for structured storage and is well-suited for caching application state, queued user actions, or datasets that need to be queried offline. Use IndexedDB for larger structured payloads where the Cache API (keyed by request) is not sufficient.

Basic IndexedDB pattern (vanilla)

The following is a minimal, well-commented example to open a database, create an object store, and provide async helper functions to put/get records.


// Minimal IndexedDB helper (not production hardened)
function openDB(dbName = 'pwa-db', version = 1) {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(dbName, version);

    req.onupgradeneeded = (event) => {
      const db = event.target.result;
      if (!db.objectStoreNames.contains('outbox')) {
        db.createObjectStore('outbox', { keyPath: 'id', autoIncrement: true });
      }
      if (!db.objectStoreNames.contains('cache')) {
        db.createObjectStore('cache', { keyPath: 'key' });
      }
    };

    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function putItem(storeName, value) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(storeName, 'readwrite');
    const store = tx.objectStore(storeName);
    const req = store.put(value);
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function getItem(storeName, key) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(storeName, 'readonly');
    const store = tx.objectStore(storeName);
    const req = store.get(key);
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

// Usage example: queue a POST payload when offline
// await putItem('outbox', { url: '/api/submit', payload: { ... }, createdAt: Date.now() });

Practical tips:

  • Consider using a lightweight library such as idb (Jake Archibald) to avoid boilerplate and gain a promise-based API.
  • Design for possible eviction: store critical state server-side or allow rehydration after a sync.
  • When using IndexedDB with service workers, be careful with contexts: service worker threads and window threads have separate connections—use messaging or sync events to coordinate.

Complete service-worker.js Example

Below is a compact, ready-to-use service-worker file that demonstrates install/activate/fetch, precaching an offline page, runtime caching for same-origin assets, and a simple cache size enforcement helper. Use this as a starting point and adapt cache names, retention policies, and routes for your app.


const CACHE_NAME = 'app-cache-v1';
const OFFLINE_URL = '/offline.html';
const PRECACHE_URLS = ['/', '/index.html', '/styles.css', '/assets/app.css', OFFLINE_URL];

// Simple LRU-ish helper: remove oldest entries when cache grows beyond limit
async function enforceCacheLimit(cacheName, maxEntries = 50) {
  const cache = await caches.open(cacheName);
  const requests = await cache.keys();
  if (requests.length <= maxEntries) return;
  const deleteCount = requests.length - maxEntries;
  for (let i = 0; i < deleteCount; i++) {
    await cache.delete(requests[i]);
  }
}

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

self.addEventListener('activate', event => {
  const expectedCaches = [CACHE_NAME];
  event.waitUntil(
    caches.keys().then(keys =>
      Promise.all(keys.map(key => (expectedCaches.includes(key) ? Promise.resolve() : caches.delete(key))))
    ).then(() => self.clients.claim())
  );
});

self.addEventListener('fetch', event => {
  const req = event.request;
  // Only handle GET requests for caching; let non-GET pass through
  if (req.method !== 'GET') return;

  // Navigation requests: try network first, fallback to cache/offline
  if (req.mode === 'navigate') {
    event.respondWith(
      fetch(req).then(networkRes => {
        // Put a copy in cache for offline use
        if (networkRes && networkRes.ok) {
          const copy = networkRes.clone();
          caches.open(CACHE_NAME).then(cache => cache.put(req, copy));
        }
        return networkRes;
      }).catch(() => caches.match(OFFLINE_URL))
    );
    return;
  }

  // For other same-origin GET requests, stale-while-revalidate
  event.respondWith(
    caches.match(req).then(cached => {
      const networkFetch = fetch(req).then(networkRes => {
        if (networkRes && networkRes.ok) {
          caches.open(CACHE_NAME).then(cache => cache.put(req, networkRes.clone()).then(() => enforceCacheLimit(CACHE_NAME, 100)));
        }
        return networkRes;
      }).catch(() => undefined);

      return cached || networkFetch.then(r => r).catch(() => cached);
    })
  );
});

Notes on adapting this file:

  • Tune enforceCacheLimit and cache names to match your storage budget and eviction policy.
  • Only cache same-origin or CORS-allowed resources; check CORS headers before caching cross-origin responses.
  • Test install/activate flows in DevTools and ensure your service-worker scope is correct (file path matters).

Workbox Integration Example

Why use Workbox?

Workbox (v6+) provides tested building blocks for precaching, runtime caching strategies, expiration, and background sync helpers. It reduces boilerplate and helps avoid common pitfalls while offering plugins and strategies for common use cases.

Basic Workbox service worker snippet

Inside a Workbox-powered service worker file you typically use the autogenerated self.__WB_MANIFEST (injected by build tooling) for precaching and then register runtime routes.


// Example service-worker.js using Workbox
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';

// Injected manifest during build by workbox plugins
precacheAndRoute(self.__WB_MANIFEST || []);

// Runtime caching for same-origin images
registerRoute(
  ({request}) => request.destination === 'image',
  new StaleWhileRevalidate({
    cacheName: 'images-runtime',
    plugins: []
  })
);

How self.__WB_MANIFEST gets generated

Use build tooling such as workbox-webpack-plugin (InjectManifest or GenerateSW) to produce and inject the manifest. Below is an example webpack plugin usage using InjectManifest so you can keep a custom service worker file while letting Workbox inject the file list.


// webpack.config.js (snippet)
const { InjectManifest } = require('workbox-webpack-plugin');

module.exports = {
  // ... your config
  plugins: [
    new InjectManifest({
      swSrc: './src/service-worker.js', // your custom service worker
      swDest: 'service-worker.js',
      maximumFileSizeToCacheInBytes: 5 * 1024 * 1024 // e.g., 5MB limit for assets
    })
  ]
};

Practical notes:

  • Use InjectManifest when you need custom logic in your SW; use GenerateSW for a fully managed SW generated by Workbox.
  • Check the Workbox repo for recipes and examples; Workbox helps implement expiration, cacheable-response checks, and background sync with fewer bugs than hand-rolled implementations.
  • Always test the generated manifest and service worker lifecycle in your CI or local build pipeline before deploying.

Testing and Debugging Offline Features in PWAs

Using Developer Tools for Testing

Chrome DevTools provides a robust environment for simulating offline scenarios. Use the Application panel to inspect service workers, storage, and caches. The Network tab can simulate network conditions (Slow 3G, Offline) so you can measure perceived load times and validate your caching strategy.

Add console.log statements inside service worker lifecycle events while developing to trace install/activate/fetch flows. When assets fail to cache, common causes are incorrect paths, scope mismatches, or missing CORS headers for cross-origin requests.

  • Use Chrome DevTools to simulate offline mode and throttling.
  • Inspect caches and service worker status in the Application panel.
  • Check for scope issues: service worker only controls pages at or below its file path.
  • Unregister and re-register service workers when iterating on behavior.

Example fetch handler that falls back to network when no cache exists (safe caching):


self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then(response =>
      response || fetch(event.request).then(networkResponse => {
        if (!networkResponse || !networkResponse.ok) return networkResponse;
        return caches.open('v1').then(cache => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
      }).catch(() => caches.match('/offline.html'))
    )
  );
});

Troubleshooting checklist:

  • Ensure the service worker file is served from the correct path (scope).
  • Confirm the server uses HTTPS in production.
  • Clear site data and unregister service workers if behavior is stale.
  • Verify response headers (CORS, cache-control) for cached resources.

Browser Compatibility and Notes

Service workers and other PWA features are widely supported across modern browsers, but capabilities and edge-case behaviors differ. Always feature-detect APIs such as navigator.serviceWorker, the Cache API, Background Sync, Notifications, and IndexedDB before using them.

iOS Safari historically had limitations around background sync, push, and periodic background APIs, and the "Add to Home Screen" flow is less programmatic. Provide clear UI guidance for iOS users to add the PWA to the home screen and use local storage/IndexedDB fallbacks where background sync is unavailable.

Chrome (Android & Desktop) and other Chromium-based browsers broadly support Background Sync, Periodic Background Sync, Payment Handler, and other progressive APIs. Use feature detection (e.g., 'periodicSync' in registration) to gate behavior and avoid runtime errors on unsupported platforms.

  • Use feature detection and graceful degradation for older or constrained browsers.
  • Provide a lightweight online-only fallback that still communicates app limitations to users.
  • Automate cross-browser tests with device farms (e.g., BrowserStack) or real-device labs for critical flows and validate on target devices.
  • IndexedDB eviction and storage quotas vary across platforms—design for potential eviction and persist critical state to the server when possible.

Key Takeaways

  • Progressive Web Apps (PWAs) enable offline functionality through service workers, allowing users to access content without an internet connection.
  • Implement caching strategies with the Cache API to ensure essential assets are available offline; version caches and prune old entries.
  • Use IndexedDB for persistent structured storage and fallbacks when background sync or other advanced APIs are not available.
  • Test PWA offline capabilities with DevTools and automated tools like Lighthouse; iterate on caching rules and fallbacks.

Frequently Asked Questions

What is a service worker and how does it work?
A service worker is a JavaScript file that runs in the background, separate from a web page. It intercepts network requests made by the web app, allowing it to cache resources and serve them even when the user is offline. To register a service worker, you typically use navigator.serviceWorker.register('/service-worker.js') in your main script. This functionality is crucial for creating a reliable PWA.
How can I test my PWA's offline capabilities?
Use browser DevTools (Application > Service Workers and the Network throttling panel) to simulate offline conditions. Run Lighthouse audits for guidance and use device farms such as BrowserStack to validate behavior on target devices.
What are the best practices for caching in PWAs?
Best practices include versioning caches, validating responses before caching, limiting cache size and entry lifetimes, and using strategies such as Cache First for static assets and Network First or Stale-While-Revalidate for dynamic content.

Conclusion

Progressive Web Apps leverage modern web technologies to deliver a seamless user experience, even in offline scenarios. Key concepts include service workers, which act as a proxy between the web app and the network, and caching strategies that ensure essential resources are available.

To further develop your PWA skills, start by building a simple application that uses service workers for caching and IndexedDB for structured offline data. Use Workbox to simplify production-grade caching and precaching, and iterate with DevTools and real-device testing.

About the Author

David Martinez

David Martinez is a Ruby on Rails Architect with 12 years of experience specializing in Ruby, Rails 7, RSpec, Sidekiq, PostgreSQL, and RESTful API design. He focuses on building scalable, secure, and high-performance web applications, with expertise in clean architecture and database-driven systems. Over the past several years he has applied PWA patterns (service workers, IndexedDB, Workbox) to Rails front-ends and API-driven single-page apps to improve offline resiliency and user experience.

Further Resources

  • MDN Web Docs — Service Worker and web platform API references and compatibility notes.
  • Workbox (GitHub) — Workbox repo with examples and recipes for precaching, runtime caching, and advanced strategies.
  • Google Developers — General guides and tooling pointers for performance and PWAs.
  • Google Chrome samples (GitHub) — Example projects demonstrating service workers and PWA patterns you can fork and run locally.
  • BrowserStack — Device cloud for cross-browser and real-device testing (useful for validating PWA behavior across platforms).

Published: Aug 23, 2025 | Updated: Jan 06, 2026