Skip to main content

🔌 Offline Functionality

Caching the app shell keeps your interface alive when the network drops — but a great offline experience does more. It shows a friendly fallback instead of an error, stores real data the user can read and edit offline, and quietly replays their actions the moment the connection returns. This lesson turns "works offline-ish" into a genuinely offline-first app.

Week 14 · Thursday: Progressive Web Apps · Lecture 3

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the "offline-first" mindset and why it produces more resilient apps
  • Serve a custom offline fallback page from the service worker's fetch handler
  • Detect and display connection state with navigator.onLine and the online/offline events
  • Store and query structured data offline with IndexedDB
  • Queue write actions offline and replay them with the Background Sync API
  • Apply an optimistic-UI pattern so the app feels instant regardless of connectivity

Estimated Time: 70 minutes

Project: Build an offline note store backed by IndexedDB that syncs pending notes when the connection returns.

In This Lesson

The Offline-First Mindset

Traditional web apps assume the network is always there and treat its absence as an error. Offline-first flips that assumption: it treats intermittent connectivity as the normal case and designs for it from the start. The network becomes an enhancement — nice when present, never required for the core experience.

📖 The analogy: planning for rain

Building a traditional app is like planning an outdoor wedding assuming perfect weather. Building offline-first is planning that same wedding with a tent on standby: you hope for sun, but a downpour doesn't cancel the event. Your app hopes for a strong connection, but a dead zone on the train doesn't stop the user from reading, writing, or getting things done.

graph LR A["Traditional app"] --> B["Assume online"] B --> C["Treat offline as an error"] D["Offline-first app"] --> E["Assume intermittent"] E --> F["Enhance when online"]

Delivering this takes three ingredients working together, each building on the last lesson's service worker:

  • A fallback page so navigations never dead-end on the browser's error screen.
  • A local database (IndexedDB) so real data is available and editable offline.
  • A sync mechanism (Background Sync) so offline changes reach the server later.

Offline Fallback Page

The simplest, highest-impact offline feature is a branded fallback page. When a user navigates somewhere you haven't cached and the network is down, you serve a friendly /offline.html instead of the browser's default error.

First, precache the fallback in the service worker's install event (as covered last lesson):

const CACHE = 'app-shell-v1';

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE).then((cache) => cache.addAll([
      '/', '/index.html', '/styles/main.css', '/offline.html'
    ]))
  );
});

Then, in fetch, use a network-first approach for navigations and fall back to the cached page when the network fails:

self.addEventListener('fetch', (event) => {
  // Only special-case page navigations.
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request).catch(() => caches.match('/offline.html'))
    );
  }
});

Your offline.html should be self-contained (its own inline CSS, since other assets may not be cached), match your branding, and tell the user what they can still do. A retry button is a nice touch:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>You're offline</title>
  <style>
    body { font-family: system-ui, sans-serif; text-align: center; padding: 3rem; }
    button { padding: 0.6rem 1.4rem; font-size: 1rem; cursor: pointer; }
  </style>
</head>
<body>
  <h1>📡 You're offline</h1>
  <p>This page isn't cached, but your saved content is still available.</p>
  <button onclick="location.reload()">Try again</button>
</body>
</html>

💡 Make the fallback self-sufficient

Inline the offline page's styles rather than linking an external stylesheet. If the connection is down and that CSS isn't cached, an externally-styled fallback appears as unstyled text — the opposite of the reassuring experience you're going for.

Detecting Connection State

Users should never be left guessing whether they're online. The browser exposes the current state through navigator.onLine and fires online and offline events on the window when it changes.

function updateStatus() {
  const banner = document.getElementById('status');
  if (navigator.onLine) {
    banner.textContent = 'Online';
    banner.className = 'online';
  } else {
    banner.textContent = 'Offline — changes will sync later';
    banner.className = 'offline';
  }
}

window.addEventListener('online', updateStatus);
window.addEventListener('offline', updateStatus);
document.addEventListener('DOMContentLoaded', updateStatus);

⚠️ navigator.onLine can lie

navigator.onLine only reports whether the device has a network connection — not whether your server is actually reachable. A phone connected to Wi-Fi with no real internet still reports true. Use it as a hint for the UI, but always let your fetch calls' success or failure be the real source of truth.

Storing Data Offline

The Cache Storage API from the last lesson is perfect for HTTP responses — files and assets. But for structured application data you can query, filter, and update — the user's notes, a draft order, a synced list — you want a real database. The browser gives you several options:

OptionCapacityAPIBest for
IndexedDBLarge (a big share of disk)Async, promise-friendlyStructured data, large sets, queries
Cache StorageLargeAsync, promise-basedHTTP responses & assets
localStorage~5–10 MBSynchronousSmall key-value settings
sessionStorage~5–10 MBSynchronousPer-tab temporary data
Cookies~4 KBSynchronousAuth tokens sent to server

For offline-first data, IndexedDB is the workhorse: it's asynchronous (never blocks the UI), holds far more than localStorage, stores real JavaScript objects, and supports indexes for fast lookups.

IndexedDB in Practice

IndexedDB is an object database. Instead of tables it has object stores, and instead of rows it stores JavaScript objects keyed by a chosen property. Its native API is event-based and verbose, so the usual pattern is to wrap it in Promises.

Opening a database

You define the schema inside the onupgradeneeded event, which fires when the database is created or its version number increases.

function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('notes-app', 1);

    // Runs on first creation or when the version number goes up.
    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      if (!db.objectStoreNames.contains('notes')) {
        const store = db.createObjectStore('notes', { keyPath: 'id', autoIncrement: true });
        store.createIndex('by_status', 'status', { unique: false });
      }
    };

    request.onsuccess = (event) => resolve(event.target.result);
    request.onerror = (event) => reject(event.target.error);
  });
}

Reading and writing

Every operation runs inside a transaction scoped to one or more stores. Here are promise-wrapped helpers for the two operations you'll use most:

async function addNote(note) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction('notes', 'readwrite');
    const store = tx.objectStore('notes');
    const request = store.add(note);
    request.onsuccess = () => resolve(request.result); // the new key
    request.onerror = () => reject(request.error);
  });
}

async function getAllNotes() {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction('notes', 'readonly');
    const request = tx.objectStore('notes').getAll();
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

Now saving a note works identically online or off — it goes to the local database first:

async function saveNote(text) {
  // Store locally right away, marked as not-yet-synced.
  const id = await addNote({ text, status: 'pending', createdAt: Date.now() });
  console.log('Saved locally as', id);
  return id;
}

✅ Optimistic UI

Because the write lands in IndexedDB instantly, you can update the interface immediately — show the new note, tick the "liked" heart — without waiting for the server. This "optimistic" pattern is what makes offline-first apps feel snappy. If a later sync fails, you reconcile then. The hallmark of a good PWA is that the user rarely notices the network at all.

📖 Reach for a wrapper library

The verbose native API is why most teams use a thin wrapper like idb, which turns the request/event dance into clean async/await. Learn the raw API once — as above — so you understand what the wrapper is doing, then let the library remove the boilerplate.

Background Sync

You've saved a note offline. How does it reach the server? You could retry whenever navigator.onLine flips to true — but that fails if the user closes the tab first. The Background Sync API solves this properly: it lets the service worker defer a task until the browser detects stable connectivity, even if the page has since been closed.

sequenceDiagram participant Page participant DB as IndexedDB participant SW as Service Worker participant Server Page->>DB: Save note as pending Page->>SW: Register a sync tagged sync-notes Note over SW: Wait for stable connection SW->>DB: Read pending notes SW->>Server: Send each pending note Server-->>SW: Saved successfully SW->>DB: Mark note as synced

Registering a sync from the page

async function queueSync() {
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('sync-notes');
    console.log('Sync queued; the browser will fire it when online.');
  } else {
    // No Background Sync support: fall back to an immediate attempt.
    await syncPendingNotes();
  }
}

Handling the sync in the service worker

The browser fires a sync event — possibly minutes later, possibly after the tab is gone — when it has a good connection. Wrap the work in event.waitUntil() so the browser keeps the worker alive until it finishes, and retries automatically if it fails.

self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-notes') {
    event.waitUntil(syncPendingNotes());
  }
});

async function syncPendingNotes() {
  const db = await openDB();
  const pending = await getNotesByStatus(db, 'pending');

  for (const note of pending) {
    try {
      const response = await fetch('/api/notes', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(note)
      });
      if (!response.ok) throw new Error(`Server returned ${response.status}`);
      note.status = 'synced';
      await putNote(db, note); // update local copy
    } catch (error) {
      console.error('Sync failed, will retry later:', error);
      throw error; // rethrow so the browser retries the whole sync
    }
  }
}

💡 Let failures rethrow

If the sync handler's Promise rejects, the browser automatically retries the sync later with a backoff — you get robust retry logic for free. That's why syncPendingNotes() rethrows on failure instead of swallowing the error.

⚠️ Feature support varies

Background Sync is well supported in Chromium browsers but not universal (Safari and Firefox lag). Always feature-detect with 'SyncManager' in window and provide a fallback — such as retrying pending items on the online event — so every user still gets their data synced eventually.

For scheduled background refreshes (pulling new data periodically rather than pushing pending changes), there's a related Periodic Background Sync API — useful for, say, downloading the morning's news for offline reading. It requires user permission and an installed PWA, and support is narrower still.

Best Practices & Pitfalls

✅ Do

  • Design offline-first: write to the local database first, sync to the server second
  • Provide a self-contained offline fallback page with inline styles
  • Use IndexedDB for structured data and Cache Storage for assets — each for its purpose
  • Communicate connection state and pending-sync status clearly in the UI
  • Feature-detect Background Sync and always have a fallback path
  • Update the UI optimistically, then reconcile if a sync fails

❌ Don't

  • Don't trust navigator.onLine as proof the server is reachable — let fetch results decide
  • Don't store large structured data in localStorage — it's tiny and synchronous (it blocks the UI)
  • Don't swallow sync errors; rethrow so the browser's retry can kick in
  • Don't forget to bump the IndexedDB version number when the schema changes
  • Don't assume every browser supports Background Sync

💡 Testing offline behavior

In Chrome DevTools, use the Network tab's Offline preset to simulate a lost connection, and the Application panel's IndexedDB and Background Services → Background Sync views to watch your stores fill and your syncs fire. Test the full loop: go offline, create data, come back online, and confirm it reaches the server.

Practice & Quiz

🏋️ Exercise 1: Offline note store

Goal: Using the helpers from this lesson, write createNote(text) that saves a note to IndexedDB as pending and then queues a background sync named sync-notes.

💡 Hint

Call addNote with a { text, status: 'pending' } object, then use navigator.serviceWorker.ready and registration.sync.register('sync-notes'). Guard the sync registration with a feature check.

✅ Solution
async function createNote(text) {
  // 1. Store locally right away.
  const id = await addNote({ text, status: 'pending', createdAt: Date.now() });

  // 2. Ask the browser to sync when it can.
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('sync-notes');
  } else {
    await syncPendingNotes(); // fallback: try now
  }
  return id;
}

🏋️ Exercise 2: Pick the right storage

Goal: For each, name the best storage mechanism: (a) 500 cached blog articles the user can read offline, (b) the user's "dark mode" preference, (c) the site's CSS and JS files.

✅ Solution
  • (a) 500 articles → IndexedDB. Large, structured, queryable data belongs in an object database.
  • (b) dark-mode flag → localStorage. A tiny key-value setting; synchronous access is fine for one boolean.
  • (c) CSS/JS files → Cache Storage. These are HTTP responses precached by the service worker.

🎯 Quick Quiz

Question 1: Which storage mechanism is best for large amounts of structured, queryable offline data?

Question 2: What is the main advantage of the Background Sync API over retrying on the online event?

Question 3: Why shouldn't you rely on navigator.onLine as proof the server is reachable?

Summary

🎉 Key Takeaways

  • Offline-first assumes intermittent connectivity and treats the network as an enhancement, not a requirement
  • Serve a self-contained offline fallback page for failed navigations from the service worker's fetch handler
  • Detect connectivity with navigator.onLine and the online/offline events — but let real fetch results be the source of truth
  • Use IndexedDB for structured offline data: asynchronous, high-capacity, object-based with indexes
  • Write locally first and update the UI optimistically; sync to the server afterward
  • The Background Sync API replays queued actions when connectivity returns, even after the tab closes — with automatic retry

📚 Additional Resources

🚀 What's Next?

You've now built a complete, offline-capable PWA — installable, reliable, and resilient. The next lesson steps into a different frontier of advanced architecture: WebAssembly Concepts, where near-native performance comes to the browser by running compiled code alongside your JavaScript.

🎉 Outstanding!

Your app now works on the subway, in an elevator, and in a dead zone — and quietly catches up the moment the signal returns. That's the promise of a PWA, delivered.