JS Guide
HomeQuestionsTopicsCompaniesResources
BookmarksSearch

Built for developers preparing for JavaScript, React & TypeScript interviews.

ResourcesQuestionsSupport
HomeQuestionsSearchProgress
HomeQuestionssystem-design
PrevNext

Learn the concept

Data Layer Architecture

system-design
senior
data-layer

Design an offline-first web application with sync capabilities

offline-first
service-worker
indexeddb
sync
crdt
cache-strategy
progressive-web-app
Quick Answer

An offline-first web application uses Service Workers for asset caching and IndexedDB for local data storage, enabling full functionality without network connectivity. Synchronization is handled through a queue-based system with conflict resolution strategies like last-write-wins or CRDTs, while optimistic UI ensures instant feedback for user actions.

Detailed Explanation

Offline-first is an architectural approach where the application is designed to work without a network connection as the default state, and network connectivity is treated as an enhancement. This is fundamentally different from "offline-capable" (where online is the default and offline is a fallback). The distinction matters because it changes how you model data flow, conflict resolution, and user expectations.

Core Architecture Layers

1. Asset Caching — Service Workers

A Service Worker intercepts all network requests and serves cached responses when the network is unavailable. This ensures the application shell (HTML, CSS, JavaScript) loads instantly regardless of connectivity.

Cache strategies:

  • Cache-first — Serve from cache, fall back to network. Best for app shell, fonts, and static assets that rarely change.
  • Stale-while-revalidate — Serve from cache immediately, then fetch a fresh copy in the background for next time. Best for content that updates periodically.
  • Network-first — Try network, fall back to cache. Best for API responses where freshness matters but offline access is still needed.
  • Network-only — Never cache. For analytics pings, write operations.

2. Data Storage — IndexedDB

IndexedDB is the only browser storage API suitable for offline-first applications because it supports structured data, indexes, transactions, and large storage quotas (typically 50-80% of available disk space, compared to localStorage's 5-10MB limit).

Design your IndexedDB schema with sync in mind:

  • Every record needs a unique ID (UUID v4, not auto-increment — auto-increment does not work across devices).
  • Every record needs an updatedAt timestamp or version vector for conflict detection.
  • Add a syncStatus field: synced, pending, conflict.
  • Create an outbox object store for queued write operations.

3. Sync Queue

All write operations (create, update, delete) are first applied to IndexedDB and then added to an outbox queue. When the network becomes available, the queue is drained in order:

  1. User performs an action (e.g., edits a note).
  2. The change is immediately written to IndexedDB (optimistic update).
  3. A sync entry is added to the outbox: { id, operation, data, timestamp, retryCount }.
  4. If online, the sync worker processes the queue immediately.
  5. If offline, the queue waits. When the online event fires, sync resumes.
  6. On success, the outbox entry is deleted and the record's syncStatus becomes synced.
  7. On conflict, the conflict resolution strategy is applied.

Conflict Resolution Strategies

Conflicts occur when two devices modify the same record while both are offline. There is no perfect solution — each strategy has trade-offs:

Last-Write-Wins (LWW)

The change with the most recent timestamp overwrites the other. Simple to implement but can silently lose data. Suitable for settings, preferences, and non-critical data.

Server-Authoritative

The server always wins conflicts. The client's version is discarded or saved as a "local draft." Suitable for financial data, inventory counts, and shared resources where consistency matters more than user autonomy.

Manual Resolution

Present both versions to the user and let them choose or merge manually. Similar to Git merge conflicts. Suitable for documents, notes, and content where user intent matters.

CRDTs (Conflict-free Replicated Data Types)

CRDTs are data structures that can be merged automatically without conflicts. Examples:

  • G-Counter — A grow-only counter (each node tracks its own count, merge by taking max).
  • LWW-Register — A single value with a timestamp (last write wins, but formalized).
  • OR-Set — An observed-remove set (additions and removals merge cleanly).
  • RGA / YATA — Replicated sequences for collaborative text editing.

CRDTs are mathematically guaranteed to converge, making them ideal for collaborative applications. Libraries like Yjs and Automerge implement them.

Optimistic UI

The user should never wait for the network. When they perform an action:

  1. Immediately update the UI as if the action succeeded.
  2. Show a subtle sync indicator (a small icon, not a spinner).
  3. If the server confirms, remove the indicator.
  4. If the server rejects, roll back the UI and show an error.

This creates the perception of a fast, responsive application even on slow or unreliable networks.

Network Status Detection

navigator.onLine is unreliable — it only detects whether the device has a network interface, not whether it can reach your server. Supplement it with:

  • Fetch probe — Periodically fetch a small endpoint (e.g., /api/ping) with a short timeout.
  • WebSocket heartbeat — If you have a WebSocket, use its connection state.
  • Exponential backoff — When a sync request fails, retry with increasing delays.

Storage Quota Management

Browsers limit storage. Use the Storage Manager API to check quota:

JavaScript
const estimate = await navigator.storage.estimate();
const usedMB = estimate.usage / 1024 / 1024;
const quotaMB = estimate.quota / 1024 / 1024;

Request persistent storage to prevent the browser from evicting your data under storage pressure: await navigator.storage.persist(). Implement a cleanup strategy for old synced data when approaching quota limits.

Progressive Enhancement

The application should work at three levels:

  1. Offline — Full read access, write to local queue, no sync indicators.
  2. Slow/unreliable network — Optimistic writes, background sync, conflict resolution.
  3. Fast network — Near-instant sync, real-time collaboration features.

Background Sync API

The Background Sync API lets you defer actions until the user has stable connectivity. Register a sync event in the Service Worker, and the browser will fire it when it detects a reliable connection — even if the user has closed the tab.

Real-World Patterns

  • Google Docs — Uses OT (Operational Transform) for real-time collaborative editing with offline support. Changes are queued locally and merged when reconnected.
  • Notion — Stores page content in IndexedDB for instant loading. Syncs blocks individually with last-write-wins at the block level.
  • Linear — Built with an offline-first architecture. The entire issue database is synced to IndexedDB, enabling instant search and navigation without network requests.
  • Figma — Uses CRDTs for multiplayer design editing. Each change is a CRDT operation that merges deterministically.

Code Examples

Service Worker with cache-first strategy for assetsJavaScript
// service-worker.js
const CACHE_NAME = 'app-shell-v2';
const ASSETS_TO_CACHE = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/manifest.json',
  '/icons/icon-192.png',
];

// Install: pre-cache app shell
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS_TO_CACHE))
  );
  self.skipWaiting(); // Activate immediately
});

// Activate: clean up old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((names) =>
      Promise.all(
        names
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      )
    )
  );
  self.clients.claim(); // Take control of all pages
});

// Fetch: apply cache strategies based on request type
self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // API requests: network-first with cache fallback
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request));
    return;
  }

  // Static assets: cache-first
  event.respondWith(cacheFirst(request));
});

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;

  try {
    const response = await fetch(request);
    if (response.ok) {
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, response.clone());
    }
    return response;
  } catch {
    return new Response('Offline', { status: 503 });
  }
}

async function networkFirst(request) {
  try {
    const response = await fetch(request);
    if (response.ok) {
      const cache = await caches.open('api-cache');
      cache.put(request, response.clone());
    }
    return response;
  } catch {
    const cached = await caches.match(request);
    return cached || new Response(JSON.stringify({ error: 'Offline' }), {
      status: 503,
      headers: { 'Content-Type': 'application/json' },
    });
  }
}

// Background Sync: process queued writes when online
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-outbox') {
    event.waitUntil(processOutbox());
  }
});

async function processOutbox() {
  const db = await openDB();
  const tx = db.transaction('outbox', 'readonly');
  const entries = await tx.objectStore('outbox').getAll();

  for (const entry of entries) {
    try {
      await fetch(entry.url, {
        method: entry.method,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(entry.data),
      });
      // Remove from outbox on success
      const deleteTx = db.transaction('outbox', 'readwrite');
      await deleteTx.objectStore('outbox').delete(entry.id);
    } catch {
      // Will retry on next sync event
      break;
    }
  }
}

Real-World Applications

Use Cases

Note-Taking Application

Users write and edit notes anywhere — on a plane, subway, or in areas with poor connectivity — and all changes sync seamlessly when they reconnect

Field Data Collection

Inspectors, surveyors, or healthcare workers collect data in remote locations without connectivity, with everything syncing when they return to coverage

Collaborative Document Editor

Multiple users edit the same document simultaneously or asynchronously, with CRDT-based conflict resolution ensuring all changes merge correctly

Mini Projects

Offline-First Todo App

advanced

Build a todo application that works completely offline using IndexedDB for storage, a sync queue for deferred writes, and last-write-wins conflict resolution when syncing with a server

Service Worker Cache Explorer

intermediate

Create a developer tool that visualizes Service Worker cache contents, shows cache hit/miss rates, and displays storage quota usage in real time

Industry Examples

Linear

Uses an offline-first architecture with IndexedDB as the primary local data store, enabling instant search, navigation, and offline access with background sync via WebSocket

Notion

Caches page content in IndexedDB for instant loading and supports offline editing with block-level sync when reconnected

Google Docs

Supports offline editing via Service Workers and IndexedDB, using Operational Transform for conflict resolution when multiple users edit simultaneously

Resources

MDN — Service Worker API

docs

web.dev — Offline Cookbook

article

MDN — IndexedDB API

docs

MDN — Background Sync API

docs

Related Questions

What is the difference between client state and server state?

junior
data-layer

How do you approach a frontend system design interview?

junior
fundamentals

Design a real-time notification system for a web application

mid
real-time
Previous
Design a food delivery tracking page with real-time updates
Next
Design a real-time chat application like Slack or Messenger
PrevNext