Skip to content

Offline Fallback Page

The simplest improvement you can make to a PWA that has no offline strategy is adding a single offline fallback page. When a user navigates to your site and the network is unavailable, instead of the browser’s error screen, they see a branded, friendly page that tells them they are offline and provides helpful context.

  1. On install — precache offline.html so it is available immediately, even before the user has ever visited any other page.
  2. On fetch — intercept navigation requests (event.request.mode === 'navigate'), try the network, and catch failures by serving the cached offline.html.

This pattern is deliberately minimal. It does not intercept sub-resources (images, scripts, API calls) — only top-level page navigations. That keeps the logic simple and avoids unintended side effects on API calls.

const OFFLINE_URL = '/offline.html';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('offline-v1').then((cache) => cache.addAll([OFFLINE_URL]))
);
self.skipWaiting();
});

event.waitUntil() keeps the install phase alive until the Promise resolves, ensuring the file is cached before the SW activates. self.skipWaiting() makes the new SW take control immediately without waiting for the old one to be released.

self.addEventListener('fetch', (event) => {
if (event.request.mode !== 'navigate') return;
event.respondWith(
fetch(event.request).catch(() => caches.match('/offline.html'))
);
});

event.request.mode === 'navigate' is true only for top-level navigation requests — clicking a link, entering a URL, or refreshing the page. Sub-resource requests (CSS, JS, XHR) have other modes ('cors', 'no-cors', 'same-origin'), so this guard ensures we only intercept page loads.

self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});

self.clients.claim() makes the service worker take control of already-open pages without requiring a reload. Without it, the current page is not controlled until the next navigation.

sequenceDiagram
  participant U as User
  participant SW as Service Worker
  participant N as Network
  participant C as Cache Storage

  U->>SW: navigate to /page (mode: navigate)
  SW->>N: fetch(request)
  alt Network available
    N-->>SW: 200 OK Response
    SW-->>U: Page renders normally
  else Network unavailable
    N--xSW: fetch() rejects
    SW->>C: caches.match('/offline.html')
    C-->>SW: cached offline.html
    SW-->>U: Offline fallback page
  end
Navigation fetch flow with offline fallback

The demo below registers a service worker that precaches offline.html. Open the StackBlitz preview, then use the browser’s DevTools Network tab to toggle offline mode and reload the page — you should see the offline fallback instead of the dinosaur.

Runs a real service worker + manifest in your browser.

A good offline fallback page should:

  • Match your brand — same fonts, colors, and logo as the rest of the app.
  • Explain the situation clearly — “You are offline” is better than a generic error.
  • Offer a retry buttonlocation.reload() is sufficient.
  • Be completely self-contained — no external stylesheets or scripts, because those will also fail offline.
  • Stay small — under 5 KB compressed so it precaches instantly.
Which event.request.mode value identifies a top-level page navigation?
Why is self.skipWaiting() called in the install event?
What does event.waitUntil() do in the install handler?
Why should an offline.html fallback page be completely self-contained (no external CSS or JS)?