PWA Offline-First Strategies-Key Steps to Enhance User Experience

Tianya SchoolTianya School
5 min read

Progressive Web Apps (PWAs) implement offline-first strategies using Service Workers and the Cache API, enabling access to parts or all of a website’s content without an internet connection.

1. Create a Service Worker File (service-worker.js)

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('my-cache-v1').then((cache) => {
      return cache.addAll([
        '/index.html',
        '/style.css',
        '/script.js',
        // Add other files to precache
      ]);
    })
  );
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      if (response) {
        return response;
      }
      return fetch(event.request).then((networkResponse) => {
        caches.open('my-cache-v1').then((cache) => {
          cache.put(event.request.url, networkResponse.clone());
        });
        return networkResponse;
      }).catch(() => {
        // Return a fallback response, like an error page, if all attempts fail
        return caches.match('/offline.html');
      });
    })
  );
});

2. Register the Service Worker

Register the Service Worker in your main application:

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

3. Update Strategy

When a new app version is available, update the Service Worker and cached content by listening to the activate event:

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.filter((cacheName) => cacheName !== 'my-cache-v1').map((cacheName) => caches.delete(cacheName))
      );
    })
  );
});

4. Update the Service Worker

To update the Service Worker, change its filename (e.g., by adding a version number). This signals the browser to treat it as a new Service Worker, triggering the installation process.

5. Manage Service Worker Lifecycle

Ensure that updating the Service Worker doesn’t disrupt user experience by allowing the old Service Worker to complete requests before closing:

self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

6. Configure the Manifest File

Create a manifest.json file to define app metadata and offline icons:

{
  "short_name": "My App",
  "name": "My Awesome Progressive Web App",
  "icons": [
    {
      "src": "icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "start_url": "/index.html",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#000000"
}

Reference the Manifest in HTML

<link rel="manifest" href="/manifest.json">

7. Offline Notifications and Reload Prompts

Notify users to reload the page for updated content when they regain connectivity:

self.addEventListener('online', (event) => {
  clients.matchAll({ type: 'window' }).then((clients) => {
    clients.forEach((client) => {
      client.postMessage({ type: 'RELOAD' });
    });
  });
});

self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'RELOAD') {
    clients.matchAll({ type: 'window' }).then((clients) => {
      clients.forEach((client) => {
        if (client.url === self.registration.scope && 'focus' in client) {
          client.focus();
          client.reload();
        }
      });
    });
  }
});

Listen for Messages in the Main App

navigator.serviceWorker.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'RELOAD') {
    alert('Network restored, refresh the page for the latest content.');
    location.reload();
  }
});

8. Offline Prompts and Experience

Provide a friendly offline page or prompt when users are offline:

<h1>You're Offline</h1>
<p>Please try again later.</p>

Handle this in the Service Worker’s fetch event:

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      if (response) {
        return response;
      }
      // Handle failed network requests
      return fetch(event.request).catch(() => {
        // Return offline page
        return caches.match('/offline.html');
      });
    })
  );
});

9. Update Cache Strategy

To cache specific resource versions instead of always using the latest, implement version control in the Service Worker:

const CACHE_NAME = 'my-cache-v2';
const urlsToCache = [
  // ...
];

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

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      if (response) {
        return response;
      }
      return fetch(event.request).then((networkResponse) => {
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request.url, networkResponse.clone());
        });
        return networkResponse;
      });
    })
  );
});

10. Use the App Shell Architecture

The App Shell model is a common PWA design pattern that provides a basic UI framework, loadable even offline. It typically includes non-dynamic content like navigation, headers, and sidebars.

Create an App Shell HTML file (e.g., app-shell.html) with basic layout and styles, then precache it in the Service Worker:

const appShellUrls = [
  '/app-shell.html',
  '/app-style.css',
  // Other App Shell-related resources
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('app-shell-cache').then((cache) => {
      return cache.addAll(appShellUrls);
    })
  );
});

Prioritize fetching App Shell resources from cache in the fetch event:

self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(caches.match('/app-shell.html'));
  } else {
    event.respondWith(
      caches.match(event.request).then((response) => {
        if (response) {
          return response;
        }
        return fetch(event.request);
      })
    );
  }
});

11. Intercept Network Requests with Service Worker

Service Workers can intercept specific network requests, like API calls, to return default or cached responses offline for a consistent user experience:

self.addEventListener('fetch', (event) => {
  if (event.request.url.startsWith('https://api.example.com')) {
    event.respondWith(
      caches.match(event.request).then((response) => {
        if (response) {
          return response;
        }
        return fetch(event.request).then((networkResponse) => {
          caches.open('api-cache').then((cache) => {
            cache.put(event.request.url, networkResponse.clone());
          });
          return networkResponse;
        });
      })
    );
  } else {
    // Handle other non-API requests
  }
});

12. Integrate WebSocket Support

For apps using WebSockets for real-time communication, use the workbox-websocket library to manage WebSocket connections in the Service Worker, ensuring messages are handled offline:

importScripts('https://unpkg.com/workbox-sw@latest/workbox-sw.js');
importScripts('https://unpkg.com/workbox-websocket@latest/workbox-websocket.js');

workbox.webSocket.register('wss://your-websocket-endpoint.com', {
  onConnect: (client) => {
    console.log('WebSocket connected:', client);
  },
  onClose: (client) => {
    console.log('WebSocket disconnected:', client);
  },
});

13. Testing and Monitoring

Test your PWA under various network conditions, including 2G, 3G, and offline, using Chrome DevTools’ network simulation. Regularly evaluate performance and offline experience with tools like Lighthouse.

14. Summary

These strategies enable the creation of a highly available PWA with excellent user experience, even in offline or weak network conditions. The goal of a PWA is to deliver a near-native app experience, making continuous optimization and testing critical.

0
Subscribe to my newsletter

Read articles from Tianya School directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Tianya School
Tianya School

❤️ • Full Stack Developer 🚀 • Building Web Apps 👨‍💻 • Learning in Public 🤗 • Software Developer ⚡ • Freelance Dev 💼 • DM for Work / Collabs 💬