Skip to main content
Progressive Web Apps·Lesson 4 of 5

Offline-First Design

Offline-first means designing your application to work without a network connection as the default, and enhancing the experience when connectivity is available. Instead of treating offline as an error state, you treat it as a normal mode of operation.

Why Offline-First?

Network connections are unreliable. Users ride elevators, take flights, pass through tunnels, and visit areas with poor coverage. An offline-first app keeps working through all of these situations.

ApproachOffline BehaviorUser Experience
Online-onlyBlank page or errorFrustrating
Offline-fallbackShows cached version of last pageAcceptable
Offline-firstFull functionality with syncSeamless

IndexedDB for Local Storage

While the Cache API stores request/response pairs, IndexedDB is a full client-side database for structured data.

Setting Up IndexedDB

function openDB(name, version) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(name, version);

    request.onupgradeneeded = (event) => {
      const db = event.target.result;

      if (!db.objectStoreNames.contains("tasks")) {
        const store = db.createObjectStore("tasks", { keyPath: "id" });
        store.createIndex("status", "status");
        store.createIndex("createdAt", "createdAt");
      }
    };

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

CRUD with IndexedDB

class TaskStore {
  constructor() {
    this.dbPromise = openDB("myapp", 1);
  }

  async getAll() {
    const db = await this.dbPromise;
    return new Promise((resolve, reject) => {
      const tx = db.transaction("tasks", "readonly");
      const store = tx.objectStore("tasks");
      const request = store.getAll();
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async add(task) {
    const db = await this.dbPromise;
    return new Promise((resolve, reject) => {
      const tx = db.transaction("tasks", "readwrite");
      const store = tx.objectStore("tasks");
      const request = store.put(task);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async delete(id) {
    const db = await this.dbPromise;
    return new Promise((resolve, reject) => {
      const tx = db.transaction("tasks", "readwrite");
      const store = tx.objectStore("tasks");
      const request = store.delete(id);
      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }
}

Using idb for Simpler IndexedDB

The raw IndexedDB API is verbose. The idb library wraps it with promises:

npm install idb
import { openDB } from "idb";

const db = await openDB("myapp", 1, {
  upgrade(db) {
    db.createObjectStore("tasks", { keyPath: "id" });
  },
});

// Add
await db.put("tasks", { id: "1", title: "Learn PWAs", status: "todo" });

// Get one
const task = await db.get("tasks", "1");

// Get all
const tasks = await db.getAll("tasks");

// Delete
await db.delete("tasks", "1");

Sync Queue Pattern

When offline, queue changes locally. When back online, sync with the server.

class SyncQueue {
  constructor() {
    this.dbPromise = openDB("sync-queue", 1, {
      upgrade(db) {
        db.createObjectStore("queue", {
          keyPath: "id",
          autoIncrement: true,
        });
      },
    });
  }

  async add(action) {
    const db = await this.dbPromise;
    await db.put("queue", {
      ...action,
      timestamp: Date.now(),
      retries: 0,
    });
  }

  async getAll() {
    const db = await this.dbPromise;
    return db.getAll("queue");
  }

  async remove(id) {
    const db = await this.dbPromise;
    await db.delete("queue", id);
  }

  async process() {
    const actions = await this.getAll();

    for (const action of actions) {
      try {
        await fetch(action.url, {
          method: action.method,
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(action.data),
        });
        await this.remove(action.id);
        console.log(`Synced: ${action.type}`);
      } catch (error) {
        console.log(`Failed to sync: ${action.type}, will retry`);
      }
    }
  }
}

const syncQueue = new SyncQueue();

// Queue an action when offline
async function createTask(task) {
  // Save locally immediately
  await taskStore.add(task);

  if (navigator.onLine) {
    await fetch("/api/tasks", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(task),
    });
  } else {
    await syncQueue.add({
      type: "CREATE_TASK",
      url: "/api/tasks",
      method: "POST",
      data: task,
    });
  }
}

// Process queue when back online
window.addEventListener("online", () => {
  syncQueue.process();
});

Background Sync API

The Background Sync API lets the service worker retry failed requests even after the user leaves the page:

// main.js — register a sync event
async function saveData(data) {
  // Store data in IndexedDB
  await db.put("outbox", data);

  // Request a background sync
  const registration = await navigator.serviceWorker.ready;
  await registration.sync.register("sync-data");
}
// sw.js — handle the sync event
self.addEventListener("sync", (event) => {
  if (event.tag === "sync-data") {
    event.waitUntil(syncOutbox());
  }
});

async function syncOutbox() {
  const db = await openDB("myapp", 1);
  const items = await db.getAll("outbox");

  for (const item of items) {
    try {
      await fetch("/api/data", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(item),
      });
      await db.delete("outbox", item.id);
    } catch (error) {
      // Sync will retry automatically
      throw error;
    }
  }
}

Online/Offline Detection

function setupConnectivityListeners() {
  function updateUI() {
    const indicator = document.getElementById("connectivity");
    if (navigator.onLine) {
      indicator.textContent = "Online";
      indicator.className = "status-online";
    } else {
      indicator.textContent = "Offline — changes will sync when reconnected";
      indicator.className = "status-offline";
    }
  }

  window.addEventListener("online", () => {
    updateUI();
    syncQueue.process();
  });

  window.addEventListener("offline", updateUI);
  updateUI();
}

Note that navigator.onLine only tells you if the device has a network interface. It does not guarantee the server is reachable. Always handle network errors gracefully regardless of this property.

Conflict Resolution

When both the client and server modify the same data while offline, you need a conflict resolution strategy:

StrategyDescriptionBest For
Last write winsMost recent timestamp overwritesSimple apps
Server winsServer version always takes priorityAuthoritative data
Client winsClient version always takes priorityUser-first apps
Manual mergeShow both versions, let user chooseCritical data
async function resolveConflict(local, remote) {
  // Last write wins
  if (local.updatedAt > remote.updatedAt) {
    await pushToServer(local);
  } else {
    await saveLocally(remote);
  }
}

Practical Exercise

Build an offline-capable note-taking app:

import { openDB } from "idb";

const db = await openDB("notes-app", 1, {
  upgrade(db) {
    const store = db.createObjectStore("notes", { keyPath: "id" });
    store.createIndex("updatedAt", "updatedAt");

    db.createObjectStore("sync-queue", {
      keyPath: "id",
      autoIncrement: true,
    });
  },
});

async function saveNote(note) {
  const data = {
    ...note,
    id: note.id || crypto.randomUUID(),
    updatedAt: Date.now(),
    synced: false,
  };

  await db.put("notes", data);

  if (navigator.onLine) {
    try {
      await fetch("/api/notes", {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      });
      await db.put("notes", { ...data, synced: true });
    } catch {
      await db.put("sync-queue", { type: "SAVE_NOTE", data });
    }
  } else {
    await db.put("sync-queue", { type: "SAVE_NOTE", data });
  }

  return data;
}

async function loadNotes() {
  return db.getAllFromIndex("notes", "updatedAt");
}

Key Takeaways

  • Offline-first treats the network as an enhancement, not a requirement.
  • IndexedDB stores structured data locally; the idb library makes it easier to use.
  • The sync queue pattern buffers changes offline and replays them when connectivity returns.
  • The Background Sync API lets service workers retry failed syncs even after the page is closed.
  • Choose a conflict resolution strategy (last write wins, server wins, manual merge) based on your data's criticality.