Progressive Web Apps have been with us for a while now, transforming the way we think about web applications and blurring the line between native and web experiences. But despite the technology’s maturity, I’ve found that many developers continue to overlook one of the most powerful PWA features: offline support. And according to recent announcements from the Chrome team, this oversight is about to become a much bigger problem.

The coming offline requirement

If you’ve deployed a PWA recently and checked your console, you might have noticed a new warning: “Page does not work offline.” This isn’t just a friendly reminder—it’s a harbinger of significant changes. Starting with Chrome 93 (scheduled for release in August), Google will be changing their installability criteria to require offline functionality. Without it, your PWA simply won’t be installable.

This change represents a fundamental shift in how Google views PWAs. The message is clear: a proper PWA should work regardless of network conditions, not just act as a glorified bookmark. And honestly, they’re right.

Why offline support matters (beyond satisfying Google)

When I first implemented offline support in a client’s e-commerce PWA last quarter, the benefits went far beyond mere compliance:

  1. User retention: Network interruptions no longer meant losing users
  2. Improved perceived performance: Even with a spotty connection, the core experience remained intact
  3. Reduced server load: Many requests could be served from cache instead of hitting our backend
  4. Better metrics: Time-to-interactive improved dramatically, boosting our Lighthouse scores

From rural areas with poor connectivity to subway commuters with intermittent signals, providing a consistent experience regardless of network conditions isn’t just good practice—it’s good business.

Implementing proper offline support

Let’s talk about how to make your PWA work offline. The foundational technology here is Service Workers, which act as a proxy between your application and the network.

There are two main approaches to caching for offline use:

1. Cache-first strategy

This approach tries to serve content from the cache first, falling back to the network only when necessary:

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(cachedResponse => {
        return cachedResponse || fetch(event.request)
          .then(response => {
            return caches.open('my-cache')
              .then(cache => {
                cache.put(event.request, response.clone());
                return response;
              });
          });
      })
  );
});

This works well for static assets and content that doesn’t change frequently.

2. Network-first with fallback

Here you attempt to fetch from the network first, only using cached content when the network fails:

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

This is better for dynamic content where freshness matters more than speed.

Workbox: Your offline support secret weapon

Writing service worker code from scratch isn’t fun. Seriously. I spent three days debugging race conditions in a custom implementation before discovering Workbox, Google’s library for service worker management.

Workbox abstracts away the complexities with an elegant API:

// In service-worker.js
importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.1.0/workbox-sw.js');

// Cache CSS, JS, and Web Worker files with a Stale-While-Revalidate strategy
workbox.routing.registerRoute(
  ({request}) => request.destination === 'style' ||
                 request.destination === 'script' ||
                 request.destination === 'worker',
  new workbox.strategies.StaleWhileRevalidate({
    cacheName: 'assets',
    plugins: [
      new workbox.expiration.ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
      }),
    ],
  }),
);

// Cache images with a Cache-First strategy
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
      }),
    ],
  }),
);

// Use Network-First for API requests
workbox.routing.registerRoute(
  ({url}) => url.pathname.startsWith('/api/'),
  new workbox.strategies.NetworkFirst({
    cacheName: 'api-responses',
    plugins: [
      new workbox.expiration.ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // 5 minutes
      }),
    ],
  }),
);

// Provide a fallback for offline navigation
workbox.routing.registerRoute(
  ({request}) => request.mode === 'navigate',
  new workbox.strategies.NetworkFirst({
    cacheName: 'pages',
    plugins: [
      new workbox.expiration.ExpirationPlugin({
        maxEntries: 50,
      }),
    ],
  }),
);

This gives you sophisticated caching strategies with just a few lines of code, and it’s what I use in production today.

The offline UX: Don’t just fail silently

A common mistake I see in PWAs is handling offline states as an error rather than a first-class experience. Instead of showing the dreaded Chrome dinosaur, consider:

  1. Custom offline pages: Create a dedicated offline experience that maintains your branding
  2. Optimistic UI: Allow users to continue interacting, queuing actions to be processed when online
  3. Background sync: Use the Background Sync API to reconcile changes when connectivity returns

For a recent client project, we implemented a “read-only mode” that activates automatically when offline, allowing users to browse previously accessed content while clearly indicating the connection status.

Testing offline functionality

Testing is essential, but many developers skip this crucial step. Here’s my testing routine:

  1. Use Chrome DevTools’ Network panel and check “Offline”
  2. Test your application in different states (first visit, return visit)
  3. Try Progressive Enhancement: start with the network disabled, then enable it
  4. Test your custom offline pages and notification systems
  5. Validate with Lighthouse PWA audits

For more thorough testing, I use Puppeteer to automate these scenarios in CI/CD pipelines. Here’s a more comprehensive test example:

const puppeteer = require('puppeteer');
const assert = require('assert');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  
  // Visit site and wait for service worker to install
  await page.goto('https://my-pwa.com');
  await page.waitForFunction(() => 
    navigator.serviceWorker.controller !== null
  );
  
  // Save the title for later comparison
  const onlineTitle = await page.title();
  
  // Check if important content is loaded
  const productElements = await page.$$('[data-test="product-item"]');
  assert(productElements.length > 0, 'Product items should be displayed while online');
  
  // Cache important page elements by visiting them
  await page.click('[data-test="nav-about"]');
  await page.waitForNavigation();
  
  // Go back to home
  await page.click('[data-test="nav-home"]');
  await page.waitForNavigation();
  
  // Disable network
  await page.setOfflineMode(true);
  
  // Reload the page and verify it loads from cache
  await page.reload();
  
  // Verify title matches to confirm page loaded
  const offlineTitle = await page.title();
  assert.strictEqual(offlineTitle, onlineTitle, 'Page title should be the same offline');
  
  // Verify offline indicator is shown
  const offlineIndicator = await page.$('[data-test="offline-indicator"]');
  assert(offlineIndicator !== null, 'Offline indicator should be visible');
  
  // Verify critical content is still available
  const offlineProductElements = await page.$$('[data-test="product-item"]');
  assert(offlineProductElements.length > 0, 'Product items should still be displayed offline');
  
  // Try to navigate to another cached page
  await page.click('[data-test="nav-about"]');
  await page.waitForNavigation({ waitUntil: 'networkidle0' });
  
  // Verify we can access this page offline
  const aboutContent = await page.$('[data-test="about-content"]');
  assert(aboutContent !== null, 'About page content should be accessible offline');
  
  // Try submitting a form offline and verify it's queued
  await page.type('[data-test="contact-form-email"]', 'test@example.com');
  await page.type('[data-test="contact-form-message"]', 'Testing offline submission');
  await page.click('[data-test="contact-form-submit"]');
  
  // Check for queue confirmation message
  await page.waitForSelector('[data-test="offline-queue-confirmation"]', { 
    visible: true,
    timeout: 5000 
  });
  
  // Turn network back on
  await page.setOfflineMode(false);
  
  // Verify background sync indicator appears
  await page.waitForSelector('[data-test="sync-in-progress"]', { 
    visible: true,
    timeout: 5000 
  });
  
  // Wait for sync to complete
  await page.waitForSelector('[data-test="sync-complete"]', { 
    visible: true,
    timeout: 10000 
  });
  
  // Take screenshot of final state
  await page.screenshot({path: 'offline-test-results.png'});
  
  await browser.close();
  console.log('PWA offline functionality test passed!');
})();

This test verifies several critical aspects of offline functionality:

  • Proper service worker installation
  • Content caching and retrieval when offline
  • Offline state indication to users
  • Navigation between cached pages
  • Form submission queuing when offline
  • Background sync when connection is restored

I’ve found that using data attributes like data-test="component-name" makes tests more resilient to UI changes, as recommended by testing best practices. Avoid relying on CSS classes or element structure which might change during design updates.

Avoiding common pitfalls

After implementing offline support for several client projects, I’ve identified some recurring issues:

  1. Over-caching: Be cautious about what you cache and for how long
  2. Under-caching: Missing critical assets makes your offline experience break
  3. Not handling POST requests: Implement strategies for form submissions while offline
  4. Forgetting to version your cache: You need a strategy for cache invalidation when you deploy updates
  5. Ignoring the App Shell model: Separate your application shell from your content for better performance

Looking forward

As PWAs continue to mature, offline support will become not just a requirement but an expectation. By implementing robust offline capabilities now, you’re not just preparing for Chrome’s upcoming changes—you’re building a more resilient application that can provide value to users regardless of their network conditions.

The web is evolving beyond its connected origins, embracing capabilities that were once exclusive to native applications. By embracing offline-first thinking, we push this evolution forward and create experiences that truly work for users, not just for ideal network conditions.