The Offline-First Mindset#

Offline-first design treats the network as an enhancement rather than a requirement. Instead of building for connectivity and then patching in offline support, you design the application to work without a network from the start. The network, when available, synchronises local state with the server.

This approach matters because network reliability is a spectrum, not a binary. Users encounter:

ScenarioReality
Underground trains, lifts, aeroplanesZero connectivity
Conference centres, stadiumsCongested, unreliable
Rural areas, emerging marketsSlow, intermittent
Office Wi-Fi switching to mobile dataBrief disconnection during handoff

An offline-first PWA handles all of these gracefully.


Offline Detection#

The navigator.onLine Property#

if (navigator.onLine) {
  console.log('Browser thinks it is online');
} else {
  console.log('Browser is offline');
}

Warning: navigator.onLine only tells you whether the device has a network interface that appears connected. It does not guarantee actual internet reachability. A laptop connected to a hotel Wi-Fi captive portal reports onLine: true but cannot reach your API.

Online/Offline Events#

window.addEventListener('online', () => {
  console.log('Network restored');
  syncPendingChanges();
});

window.addEventListener('offline', () => {
  console.log('Network lost');
  showOfflineBanner();
});

Reliable Connectivity Check#

For critical operations, verify actual reachability:

async function isReachable(url = '/api/health') {
  try {
    const response = await fetch(url, {
      method: 'HEAD',
      cache: 'no-store',
      signal: AbortSignal.timeout(5000),
    });
    return response.ok;
  } catch {
    return false;
  }
}

IndexedDB for Client-Side Storage#

IndexedDB is the primary client-side database for structured data in PWAs. Unlike localStorage (5 MB, synchronous, strings only), IndexedDB supports large volumes, binary data, indexes, and transactions.

Storage Comparison#

StorageCapacityData TypesAsyncIndexesUse Case
localStorage~5 MBStrings only❌❌Small key-value settings
sessionStorage~5 MBStrings only❌❌Tab-scoped temp data
IndexedDBQuota-based (GBs)Structured clones✅✅Application data, offline cache
Cache APIQuota-basedRequest/Response pairs✅❌HTTP response caching

Opening a Database#

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('articles')) {
        const store = db.createObjectStore('articles', { keyPath: 'id' });
        store.createIndex('byDate', 'updatedAt');
        store.createIndex('byCategory', 'category');
      }

      if (!db.objectStoreNames.contains('pendingSync')) {
        db.createObjectStore('pendingSync', { keyPath: 'id', autoIncrement: true });
      }
    };

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

CRUD Operations#

// Write
async function saveArticle(db, article) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('articles', 'readwrite');
    tx.objectStore('articles').put(article);
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

// Read by key
async function getArticle(db, id) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('articles', 'readonly');
    const request = tx.objectStore('articles').get(id);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// Read all by index
async function getArticlesByCategory(db, category) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('articles', 'readonly');
    const index = tx.objectStore('articles').index('byCategory');
    const request = index.getAll(category);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// Delete
async function deleteArticle(db, id) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('articles', 'readwrite');
    tx.objectStore('articles').delete(id);
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

Using a Wrapper Library#

The raw IndexedDB API is verbose. Production apps commonly use a thin wrapper like idb:

import { openDB } from 'idb';

const db = await openDB('my-app', 1, {
  upgrade(db) {
    const store = db.createObjectStore('articles', { keyPath: 'id' });
    store.createIndex('byDate', 'updatedAt');
  },
});

// Write
await db.put('articles', { id: '1', title: 'Hello', updatedAt: Date.now() });

// Read
const article = await db.get('articles', '1');

// Query by index
const recent = await db.getAllFromIndex('articles', 'byDate');

// Delete
await db.delete('articles', '1');

Background Sync API#

The Background Sync API allows a service worker to defer actions until the user has a stable network connection. Instead of failing a POST request when offline, you queue the operation and let the browser retry it when connectivity is restored.

Registering a Sync#

// In the page — queue a sync request
async function submitForm(data) {
  // Save data to IndexedDB first
  const db = await openDB('my-app', 1);
  await db.put('pendingSync', { ...data, createdAt: Date.now() });

  // Register a background sync
  const registration = await navigator.serviceWorker.ready;
  await registration.sync.register('sync-form-data');

  showNotification('Saved! Will sync when online.');
}

Handling the Sync Event#

// sw.js
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-form-data') {
    event.waitUntil(syncFormData());
  }
});

async function syncFormData() {
  const db = await openDB('my-app', 1);
  const pending = await db.getAll('pendingSync');

  for (const item of pending) {
    try {
      await fetch('/api/submit', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(item),
      });
      await db.delete('pendingSync', item.id);
    } catch (error) {
      // Throwing causes the browser to retry the sync later
      throw error;
    }
  }
}

The browser retries the sync event with exponential backoff. If the waitUntil promise rejects, the sync is retried. If it resolves, the sync is complete.


Conflict Resolution#

When multiple clients modify the same data offline, conflicts are inevitable when they sync. There is no universal solution — the strategy depends on your data model and business requirements.

Common Strategies#

StrategyHow It WorksBest For
Last-write-winsMost recent timestamp overwritesSimple settings, user preferences
Server-winsServer version always takes precedenceAuthoritative data sources
Client-winsClient version overwrites serverDraft documents, local-first apps
Manual mergePresent both versions to the userCollaborative editing
CRDTConflict-free replicated data typesReal-time collaborative apps
Operational transformTransform concurrent operationsGoogle Docs-style collaboration

Last-Write-Wins Implementation#

async function syncArticle(local, remote) {
  if (!remote) {
    // New local article — push to server
    return pushToServer(local);
  }

  if (local.updatedAt > remote.updatedAt) {
    // Local is newer — push to server
    return pushToServer(local);
  }

  if (remote.updatedAt > local.updatedAt) {
    // Remote is newer — update local
    return saveLocally(remote);
  }

  // Same timestamp — no conflict
  return local;
}

Version Vector Approach#

For multi-device sync, track a version counter per device:

const article = {
  id: 'article-1',
  title: 'My Post',
  content: '...',
  versions: {
    'device-a': 3,
    'device-b': 2,
  },
};

function isNewer(incoming, existing) {
  // Incoming is newer if ALL its version counters are ≥ the existing ones
  return Object.entries(incoming.versions).every(
    ([device, version]) => version >= (existing.versions[device] || 0)
  );
}

Offline UX Patterns#

Show Current State Clearly#

Always indicate the connectivity state and data freshness:

function updateStatusUI() {
  const banner = document.getElementById('status-banner');

  if (!navigator.onLine) {
    banner.textContent = 'You are offline. Changes will sync when reconnected.';
    banner.className = 'banner banner-warning';
    banner.hidden = false;
  } else {
    banner.hidden = true;
  }
}

window.addEventListener('online', updateStatusUI);
window.addEventListener('offline', updateStatusUI);

Optimistic UI Updates#

Apply changes to the UI immediately, then confirm with the server in the background:

async function likePost(postId) {
  // 1. Update UI immediately
  updateLikeCount(postId, +1);

  // 2. Queue the server request
  try {
    await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
  } catch {
    // 3. If offline, queue for background sync
    await queueForSync({ action: 'like', postId });
    showToast('Like saved — will sync when online');
  }
}

Offline Fallback Page#

Serve a custom offline page when the user navigates to an uncached URL:

// sw.js — pre-cache an offline fallback
const OFFLINE_PAGE = '/offline.html';

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('offline-v1').then((cache) => cache.add(OFFLINE_PAGE))
  );
});

self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request).catch(() => caches.match(OFFLINE_PAGE))
    );
  }
});

Key UX Principles#

PrincipleImplementation
Never show a blank screenPre-cache the app shell and an offline fallback page
Show data ageDisplay “Last updated: 5 minutes ago” alongside cached content
Queue user actionsSave writes to IndexedDB and sync later instead of showing errors
Confirm sync completionNotify users when background sync completes successfully
Degrade gracefullyDisable features that require connectivity rather than hiding them

Key Takeaways#

  • Offline-first treats the network as an enhancement — design for no connectivity first, then layer on network access.
  • navigator.onLine is unreliable for actual internet reachability; use a fetch-based health check for critical operations.
  • IndexedDB is the primary offline data store for structured data — use a wrapper library like idb to avoid the verbose raw API.
  • The Background Sync API queues operations until the browser has a stable connection, with automatic retry and exponential backoff.
  • Conflict resolution must be an explicit design decision — last-write-wins is simple but not always correct; consider version vectors or CRDTs for collaborative scenarios.
  • Clear status indicators, optimistic UI updates, and offline fallback pages are essential UX patterns that make offline behaviour feel intentional rather than broken.