Skip to main content
On this page

Offline-First Architecture

Building applications that prioritize local data and functionality, treating network connectivity as an enhancement rather than a requirement—the storage APIs, sync strategies, and conflict resolution patterns that power modern collaborative and offline-capable applications.

Offline-first inverts the traditional web model: instead of fetching data from servers and caching it locally, data lives locally first and syncs to servers when possible. This article explores the browser APIs that enable this pattern, the sync strategies that keep data consistent, and how production applications like Figma, Notion, and Linear solve these problems at scale.

Offline-first architecture: the app reads and writes to IndexedDB or OPFS immediately, the Service Worker intercepts fetches and runs background sync, and reconciliation happens between the local store and the server when connectivity returns.
Offline-first architecture: the app reads and writes locally first; Service Worker handles caching and background sync; reconciliation happens between the local store and the server.

Abstract

Offline-first architecture treats local storage as the primary data source and network as a sync mechanism. The core mental model:

  • Local-first data: Application reads from and writes to local storage (IndexedDB, OPFS) immediately. Network operations are asynchronous background tasks, not blocking user interactions.

  • Service Workers as network proxy: Service Workers intercept all network requests, enabling caching strategies (cache-first, network-first, stale-while-revalidate) and background sync when connectivity returns.

  • Conflict resolution is the hard problem: When multiple clients modify the same data offline, syncing creates conflicts. Three approaches: Last-Write-Wins (simple but loses data), Operational Transform (requires central server), and CRDTs (mathematically guaranteed convergence but complex).

  • Storage is constrained and unreliable: Browser storage quotas are mostly disk-percentage based (Chrome and Safari around 60% of disk per origin, Firefox 10%/50%), but Safari evicts the entire script-writable surface after seven days of browser use without site interaction. Persistent storage helps in Chromium/Firefox but is a no-op in Safari.

Pattern Complexity Data Loss Risk Offline Duration Best For
Cache-only Low High (stale data) Minutes Static assets
Sync queue Medium Medium (conflicts) Hours Form submissions
OT-based High Low Days Real-time collab
CRDT-based Very High None Indefinite P2P, long offline

Local-First Principles

“Offline-first” is sometimes used loosely to mean “the page doesn’t blow up on a flaky train”. The stricter framing comes from Kleppmann et al., “Local-first software” (Ink & Switch, 2019), which sets seven ideals a fully local-first system aims for:

  1. No spinners — the app responds to input without round-tripping a server.
  2. Your work is not trapped on one device — state syncs across the user’s devices.
  3. The network is optional — full functionality offline; sync runs in the background when a connection appears.
  4. Seamless collaboration — real-time multi-user editing on par with cloud apps.
  5. Long-term preservation — data outlives the application and the company that shipped it.
  6. Security and privacy by default — end-to-end encryption; the server holds ciphertext it cannot read.
  7. You retain ultimate ownership and control — the user can fork, export, and walk away.

Most production “offline-first” web apps satisfy 1–4 and partially 5; ideals 6 and 7 are usually only met by P2P + CRDT systems like Excalidraw or Ink & Switch’s own research prototypes. Treat the seven ideals as a target gradient, not a binary check — every architecture in this article makes a different cut.

The Challenge

Browser Constraints

Building offline-first applications means working within browser limitations that don’t exist in native apps.

Main thread contention: IndexedDB operations are asynchronous but still affect the main thread. Large reads/writes can cause jank. Service Workers run on a separate thread but share CPU with the page.

Storage quotas: Browsers limit how much data an origin can store, and quotas vary dramatically. The numbers below are from the MDN storage quotas reference (2026):

Browser Best-Effort Mode Persistent Mode Eviction Behavior
Chrome Up to 60% of disk per origin Up to 60% of disk per origin LRU once group quota (80% of disk) is full
Firefox Smaller of 10% disk or 10 GiB (per eTLD+1) Up to 50% of disk, capped 8 TiB LRU per group, after user prompt
Safari Up to 60% of disk per origin (browser app) Persistence flag is no-op 7 days of browser use without interaction

Note

Safari’s ~1 GB cap is a frequently repeated but obsolete number. WebKit moved to disk-percentage quotas (60% per origin in the browser app, 15% in embedded WebViews) several releases back. The headline constraint on Safari today is the 7-day eviction, not the size cap.

Safari’s aggressive eviction: Safari deletes all script-writable storage (IndexedDB, Cache API, localStorage, sessionStorage, Service Worker registrations) after seven days of Safari use without a meaningful interaction on the site. “Seven days of Safari use” means days the browser is actively used, not seven calendar days — and the only exemptions are home-screen PWAs, which carry their own usage counter. This fundamentally breaks long-term offline storage for Safari users on web pages.

Storage API fragmentation: Different storage mechanisms have different characteristics:

Storage Type Max Size Persistence Indexed Transaction Support
localStorage 5MB Session/persistent No No
sessionStorage 5MB Tab session No No
IndexedDB Origin quota Persistent Yes Yes
Cache API Origin quota Persistent No No
OPFS Origin quota Persistent No No

Network Realities

navigator.onLine is unreliable: MDN explicitly warns that browsers may not have a reliable way to know whether the device can actually reach the internet. The flag flips on whether the browser has any network interface; a LAN connection behind a captive portal or a firewall that blocks egress will still report online: true.

Typescript
// Don't rely on this for actual connectivitynavigator.onLine // true even without internet access// Instead, detect actual connectivityasync function checkConnectivity(): Promise<boolean> {  try {    const response = await fetch("/api/health", {      method: "HEAD",      cache: "no-store",    })    return response.ok  } catch {    return false  }}

Network transitions are complex: Users move between WiFi, cellular, and offline. Requests can fail mid-flight. Servers can be reachable but slow. Offline-first apps must handle all these states gracefully.

Scale Factors

The right offline strategy depends on data characteristics:

Factor Simple Offline Full Offline-First
Data size < 10MB 100MB+
Update frequency < 1/hour Real-time
Concurrent editors Single user Multiple users
Offline duration Minutes Days/weeks
Conflict complexity Overwrites acceptable Must preserve all edits

Storage Layer

IndexedDB: The Foundation

IndexedDB is the primary storage mechanism for offline-first apps. It’s a transactional, indexed object store that can handle large amounts of structured data.

Transaction model: IndexedDB uses transactions with three modes:

  • readonly: Multiple concurrent reads allowed
  • readwrite: Serialized writes, blocks other readwrite transactions on same object stores
  • versionchange: Schema changes, exclusive access to entire database
Typescript
function openDatabase(): Promise<IDBDatabase> {  return new Promise((resolve, reject) => {    const request = indexedDB.open(DB_NAME, DB_VERSION)    request.onupgradeneeded = (event) => {      const db = request.result      const oldVersion = event.oldVersion      // Version 1: Initial schema      if (oldVersion < 1) {        const store = db.createObjectStore("documents", { keyPath: "id" })        store.createIndex("by_updated", "updatedAt")      }      // Version 2: Add sync metadata      if (oldVersion < 2) {        const store = request.transaction!.objectStore("documents")        store.createIndex("by_sync_status", "syncStatus")

Versioning is critical: IndexedDB schema changes require version increments. Opening a database with a lower version than exists fails. The onupgradeneeded handler must handle all version migrations sequentially.

Cursor operations for large datasets: For datasets too large to load entirely, use cursors. Each cursor.continue() re-fires onsuccess on the original request, so a single onsuccess handler per cursor is the correct shape:

iterate-documents.ts
async function* iterateDocuments(db: IDBDatabase): AsyncGenerator<Document> {  const tx = db.transaction("documents", "readonly")  const store = tx.objectStore("documents")  const request = store.openCursor()  while (true) {    const cursor = await new Promise<IDBCursorWithValue | null>((resolve, reject) => {      request.onsuccess = () => resolve(request.result)      request.onerror = () => reject(request.error)    })    if (!cursor) return    yield cursor.value as Document    cursor.continue()  }}

Caution

IndexedDB transactions auto-commit when the event loop returns to the microtask queue with no pending requests. If you await a Promise that does not chain another IndexedDB request, the transaction commits and the next cursor.continue() throws TransactionInactiveError. Inside an async generator, the consumer’s awaits between yields are exactly such gaps — wrap heavy per-row work outside the cursor (collect IDs first, then re-open a transaction) when consumer code is async.

Origin Private File System (OPFS)

OPFS provides file system access within the browser sandbox — faster than IndexedDB for binary data and large files. The full surface lives in the WHATWG File System Standard and is documented end-to-end on MDN.

When to use OPFS over IndexedDB:

  • Binary files (images, videos, documents)
  • Large blobs (>10 MB)
  • Sequential read/write patterns
  • Web Workers where synchronous access is acceptable

Important

All OPFS handle lookups (navigator.storage.getDirectory(), getFileHandle(), getDirectoryHandle()) are asynchronous on every thread. The synchronous file API only kicks in after you have an async file handle and call createSyncAccessHandle() — and the resulting FileSystemSyncAccessHandle only works inside a dedicated Web Worker.

opfs-main-thread.ts
async function saveFile(name: string, data: ArrayBuffer): Promise<void> {  const root = await navigator.storage.getDirectory()  const fileHandle = await root.getFileHandle(name, { create: true })  const writable = await fileHandle.createWritable()  await writable.write(data)  await writable.close()}
opfs-worker.ts
self.addEventListener("message", async (event: MessageEvent<{ name: string; data: ArrayBuffer }>) => {  const { name, data } = event.data  const root = await navigator.storage.getDirectory()  const fileHandle = await root.getFileHandle(name, { create: true })  const accessHandle = await fileHandle.createSyncAccessHandle()  try {    accessHandle.write(data, { at: 0 })    accessHandle.flush()  } finally {    accessHandle.close()  }})

OPFS limitations:

  • No indexing (unlike IndexedDB) — you manage your own file organization.
  • The fast sync API is Worker-only; main-thread code uses the async createWritable() writer.
  • No cross-origin access; sandbox is per origin.
  • Same quota as IndexedDB (shared origin quota).

Storage Manager API

The Storage Manager API provides quota information and persistence requests:

Typescript
async function checkStorageStatus(): Promise<{  quota: number  usage: number  persistent: boolean}> {  const estimate = await navigator.storage.estimate()  const persistent = await navigator.storage.persisted()  return {    quota: estimate.quota ?? 0,    usage: estimate.usage ?? 0,    persistent,  }}async function requestPersistence(): Promise<boolean> {  // Chrome auto-grants for "important" sites (bookmarked, installed PWA)  // Firefox prompts the user  // Safari doesn't support persistent storage  if (navigator.storage.persist) {    return await navigator.storage.persist()  }  return false}

Persistence reality: Without persistent storage, browsers can evict your data at any time when storage pressure occurs. Chrome uses LRU eviction by origin. Safari’s 7-day limit applies regardless of persistence requests.

Design implication: Never assume local data will survive. Always design for re-sync from server. Treat local storage as a cache that improves UX, not as the source of truth.

Service Workers

Service Workers are JavaScript workers that intercept network requests, enabling offline functionality and background sync.

Lifecycle

Service Workers have a distinct lifecycle that affects how updates propagate. The official model lives in the W3C Service Workers spec, §2 Lifecycle; a more readable narrative is on web.dev.

Service Worker lifecycle as a state machine: register, install, install handler resolves, waiting until existing clients close (or skipWaiting), activate, then alternating between idle and running on each event, with the browser free to terminate idle workers.
Service Worker lifecycle: install, wait for old clients to close, activate, then idle/running cycles driven by fetch, sync, and message events.

Installation: Service Worker is downloaded and parsed. install event fires — use this to pre-cache critical assets.

Waiting: New Service Worker waits until all clients controlled by the old version close. This prevents breaking in-flight requests.

Activation: Old Service Worker is replaced. activate event fires — use this to clean up old caches.

Typescript
self.addEventListener("install", (event: ExtendableEvent) => {  event.waitUntil(    caches.open(STATIC_CACHE).then((cache) => {      return cache.addAll(["/", "/app.js", "/styles.css", "/offline.html"])    }),  )  // Skip waiting to activate immediately (use carefully)  // self.skipWaiting();})self.addEventListener("activate", (event: ExtendableEvent) => {  event.waitUntil(    caches.keys().then((keys) => {      return Promise.all(keys.filter((key) => !key.includes(CACHE_VERSION)).map((key) => caches.delete(key)))    }),  )  // Take control of all pages immediately  // self.clients.claim();})

skipWaiting pitfall: Calling skipWaiting() activates the new Service Worker immediately, but existing pages still have old JavaScript. This can cause version mismatches between page code and Service Worker. Only use if your update is backward-compatible.

Caching Strategies

Jake Archibald’s Offline Cookbook defines the canonical caching strategies. Each has distinct trade-offs.

Cache-priority Service Worker strategies: cache-first returns the cached copy and only goes to network on miss, cache-only returns cached or an offline page and never touches the network.
Cache-priority strategies — cache-first and cache-only. Both consult the cache first; only cache-first falls back to the network on miss.

Network-aware Service Worker strategies: network-first races the network with a timeout and falls back to cache, stale-while-revalidate returns the cached copy immediately and refreshes the cache in the background.
Network-aware strategies — network-first and stale-while-revalidate. Both touch the network on every request; SWR returns the cached copy first while it refreshes.

Cache-First: Serve from cache, fall back to network. Best for static assets that rarely change.

Typescript
async function cacheFirst(request: Request): Promise<Response> {  const cached = await caches.match(request)  if (cached) return cached  const response = await fetch(request)  if (response.ok) {    const cache = await caches.open(STATIC_CACHE)    cache.put(request, response.clone())  }  return response}

Network-First: Try network, fall back to cache. Best for frequently-updated content where freshness matters.

Typescript
    const controller = new AbortController()    const timeoutId = setTimeout(() => controller.abort(), timeout)    const response = await fetch(request, { signal: controller.signal })    clearTimeout(timeoutId)    if (response.ok) {      const cache = await caches.open(DYNAMIC_CACHE)      cache.put(request, response.clone())    }    return response  } catch {    const cached = await caches.match(request)    if (cached) return cached    throw new Error("Network failed and no cache available")

Stale-While-Revalidate: Serve from cache immediately, update cache in background. Best for content where slight staleness is acceptable.

Typescript
async function staleWhileRevalidate(request: Request): Promise<Response> {  const cache = await caches.open(DYNAMIC_CACHE)  const cached = await cache.match(request)  const fetchPromise = fetch(request).then((response) => {    if (response.ok) {      cache.put(request, response.clone())    }    return response  })  return cached ?? fetchPromise}

Strategy selection by resource type:

Resource Type Strategy Rationale
App shell (HTML, JS, CSS) Cache-first with version Immutable builds
API responses Network-first Freshness critical
User-generated content Stale-while-revalidate UX + eventual freshness
Images/media Cache-first Rarely change
Authentication endpoints Network-only Must be fresh

Background Sync

Background Sync API allows deferring actions until connectivity is available:

Typescript
  // Register for background sync  const registration = await navigator.serviceWorker.ready  await registration.sync.register("sync-pending-changes")}// In service workerself.addEventListener("sync", (event: SyncEvent) => {  if (event.tag === "sync-pending-changes") {    event.waitUntil(processSyncQueue())  }})async function processSyncQueue(): Promise<void> {  const pending = await getPendingSyncItems()  for (const item of pending) {    try {      await fetch("/api/sync", {        method: "POST",

Background Sync limitations (per MDN compatibility data):

  • Chromium-only as of 2026 — neither Firefox nor Safari ship SyncManager.
  • No guarantee of timing — the browser decides when to fire the sync event based on connectivity and engagement signals.
  • Service worker scripts have a tight execution budget; long-running sync work can be terminated.
  • Requires an active Service Worker registration on a secure origin.

Periodic Background Sync: Allows periodic sync even when the app is closed. Requires the periodic-background-sync permission, an installed PWA, and is also Chromium-only:

register-periodic-sync.ts
const registration = await navigator.serviceWorker.readyif ("periodicSync" in registration) {  const status = await navigator.permissions.query({    name: "periodic-background-sync" as PermissionName,  })  if (status.state === "granted") {    await (registration as any).periodicSync.register("sync-content", {      minInterval: 24 * 60 * 60 * 1000,    })  }}

Note

minInterval is a hint, not a floor. Chrome ignores it whenever site engagement is low and may delay or skip wake-ups entirely. Treat periodic sync as best-effort freshness, never as a primary sync path.

Workbox

Workbox (Google) encapsulates Service Worker patterns in a production-ready library — precaching with revision hashes, route-level caching strategies, and pluggable expiration / sync queues. Adoption sits in the low single digits of mobile sites that ship a Service Worker per the HTTP Archive 2025 Web Almanac PWA chapter; the bigger story is that overall Service Worker adoption jumped because Google Tag Manager started auto-installing one.

Typescript
// API calls: network-first with background sync fallbackregisterRoute(  ({ url }) => url.pathname.startsWith("/api/"),  new NetworkFirst({    cacheName: "api-cache",    plugins: [      new BackgroundSyncPlugin("api-queue", {        maxRetentionTime: 24 * 60, // 24 hours      }),    ],  }),)// Images: cache-firstregisterRoute(  ({ request }) => request.destination === "image",

Why use Workbox:

  • Handles cache versioning and cleanup automatically
  • Precaching with revision hashing
  • Built-in plugins for expiration, broadcast updates, background sync
  • Webpack/Vite integration for build-time manifest generation

Sync Strategies

When clients make changes offline, syncing those changes creates the hardest problems in offline-first architecture.

Last-Write-Wins (LWW)

The simplest conflict resolution: most recent timestamp wins.

Typescript
interface Document {  id: string  content: string  updatedAt: number // Unix timestamp}function resolveConflict(local: Document, remote: Document): Document {  return local.updatedAt > remote.updatedAt ? local : remote}

When LWW works:

  • Single-user applications
  • Data where loss is acceptable (analytics, logs)
  • Coarse-grained updates (entire document, not fields)

When LWW fails:

  • Multi-user editing (Alice’s changes overwrite Bob’s)
  • Fine-grained updates (field-level changes lost)
  • Clock skew between clients causes wrong “winner”

Clock skew problem: Client clocks can drift. A device with clock set to the future always wins. Solutions:

  • Use server timestamps (but requires connectivity)
  • Hybrid logical clocks (Lamport timestamp + physical time)
  • Vector clocks (discussed below)

Vector Clocks

Vector clocks track causality—which events “happened before” others—without synchronized physical clocks.

Typescript
type VectorClock = Map<string, number>function increment(clock: VectorClock, nodeId: string): VectorClock {  const newClock = new Map(clock)  newClock.set(nodeId, (newClock.get(nodeId) ?? 0) + 1)  return newClock}function merge(a: VectorClock, b: VectorClock): VectorClock {  const result = new Map(a)  for (const [nodeId, count] of b) {    result.set(nodeId, Math.max(result.get(nodeId) ?? 0, count))  }  return result}function compare(a: VectorClock, b: VectorClock): "before" | "after" | "concurrent" {  let aBefore = false  let bBefore = false  const allNodes = new Set([...a.keys(), ...b.keys()])  for (const nodeId of allNodes) {    const aCount = a.get(nodeId) ?? 0    const bCount = b.get(nodeId) ?? 0    if (aCount < bCount) aBefore = true    if (bCount < aCount) bBefore = true  }  if (aBefore && !bBefore) return "before"  if (bBefore && !aBefore) return "after"  return "concurrent" // True conflict}

Vector clocks detect conflicts but don’t resolve them: When compare returns 'concurrent', you have a true conflict that needs application-specific resolution.

Space overhead: Vector clocks grow with number of writers. For N clients, each entry is O(N). Dynamo-style systems use “version vectors” with pruning.

Operational Transform (OT)

Operational Transformation models changes as operations that can be transformed when concurrent. The original Ellis & Gibbs paper “Concurrency Control in Groupware Systems” (SIGMOD 1989) introduced it, and Google’s Wave / Docs team eventually shipped it at scale.

How OT works:

  1. Client captures operations: insert('Hello', position: 0)
  2. Client applies operation locally (optimistic update)
  3. Client sends operation to server
  4. Server transforms operation against concurrent operations
  5. Server broadcasts transformed operation to other clients
Typescript
interface TextOperation {  type: "insert" | "delete"  position: number  text?: string // for insert  length?: number // for delete}// Transform op1 given op2 was applied firstfunction transform(op1: TextOperation, op2: TextOperation): TextOperation {  if (op2.type === "insert") {    if (op1.position >= op2.position) {      return { ...op1, position: op1.position + op2.text!.length }    }  } else if (op2.type === "delete") {    if (op1.position >= op2.position + op2.length!) {      return { ...op1, position: op1.position - op2.length! }    }    // More complex cases: overlapping deletes, etc.  }  return op1}

OT requires central coordination: The server maintains operation history and performs transforms. This means OT doesn’t work for true peer-to-peer or extended offline scenarios.

OT complexity: Transformation functions are notoriously difficult to get right. Google Docs has had OT-related bugs despite years of engineering. The transformation must satisfy mathematical properties (convergence, intention preservation) that are hard to verify.

Where OT excels: Real-time collaborative editing with always-on connectivity. Low latency because changes apply immediately with optimistic updates.

CRDTs (Conflict-free Replicated Data Types)

CRDTs are data structures mathematically designed to merge without conflicts: any order of applying changes converges to the same result. The foundational treatment is Shapiro et al., “Conflict-free Replicated Data Types” (INRIA RR-7687, 2011).

Two types of CRDTs:

State-based (CvRDT): Replicate entire state, merge using mathematical join.

Typescript
// G-Counter: Grow-only countertype GCounter = Map<string, number>function increment(counter: GCounter, nodeId: string): GCounter {  const newCounter = new Map(counter)  newCounter.set(nodeId, (newCounter.get(nodeId) ?? 0) + 1)  return newCounter}function merge(a: GCounter, b: GCounter): GCounter {  const result = new Map(a)  for (const [nodeId, count] of b) {    result.set(nodeId, Math.max(result.get(nodeId) ?? 0, count))  }  return result}function value(counter: GCounter): number {  return Array.from(counter.values()).reduce((sum, n) => sum + n, 0)}

Operation-based (CmRDT): Replicate operations, apply in any order. Requires reliable delivery (all operations eventually arrive).

Common CRDT types:

CRDT Use Case Trade-off
G-Counter Likes, views Grow-only
PN-Counter Votes (up/down) Two G-Counters
G-Set Tags, followers Grow-only set
OR-Set (Observed-Remove) General sets Handles concurrent add/remove
LWW-Register Single values Last-write-wins
RGA (Replicated Growable Array) Text editing Complex, high overhead

Text CRDTs: For collaborative text editing, specialized CRDTs like RGA, WOOT, or Yjs’s implementation track character positions with unique IDs that survive concurrent edits.

Typescript
// Simplified RGA node structureinterface RGANode {  id: { clientId: string; seq: number }  char: string  tombstone: boolean // Deleted but kept for ordering  after: RGANode["id"] | null // Insert position}

CRDT trade-offs:

  • Pros: Mathematically guaranteed convergence, works fully offline, no central server required
  • Cons: High memory overhead (tombstones, metadata), complex implementation, eventual consistency only

“CRDTs are the only data structures that can guarantee consistency in a fully decentralized system, but many published algorithms have subtle bugs. It’s easy to implement CRDTs badly.” — Martin Kleppmann

Interleaving anomaly: When two users type “foo” and “bar” at the same position, naive CRDTs (notably Logoot and LSEQ) can produce “fboaor” instead of “foobar” or “barfoo”. Yjs (YATA) and Automerge use heuristics that reduce this in two-replica edits but can still interleave under three-way concurrent inserts; recent algorithms like Fugue and FugueMax (Weidner et al., 2023) explicitly satisfy a maximal non-interleaving property at comparable performance. The seminal write-up of the problem is Kleppmann et al., PaPoC 2019.

Sync Strategy Comparison

Factor LWW Vector Clocks OT CRDT
Conflict resolution Automatic (lossy) Detect only Server-based Automatic (lossless)
Offline duration Any Any Short (needs server) Any
Implementation complexity Low Medium High Very High
Memory overhead Low Medium Low High
P2P support Yes Partial No Yes
Data loss risk High Application-dependent Low None

Design Paths

Path 1: Cache-Only PWA

Architecture: Service Worker caches static assets and API responses. No local database. Changes require network.

Text
Browser → Service Worker → Cache API → (Network when available)

Best for:

  • Read-heavy applications (news, documentation)
  • Short offline periods (subway, airplane mode)
  • Content that doesn’t change offline

Implementation complexity:

Aspect Effort
Initial setup Low
Feature additions Low
Sync logic None
Testing Low

Device/network profile:

  • Works well on: All devices, any network
  • Struggles on: Extended offline, collaborative editing

Trade-offs:

  • Simplest implementation
  • No sync conflicts
  • Limited offline functionality
  • Stale data possible

Path 2: Sync Queue Pattern

Architecture: Changes stored in IndexedDB queue, processed when online. Server is source of truth.

Sequence diagram for the sync queue pattern: user edits, the client (app + Service Worker + IndexedDB) queues the change and renders an optimistic update, then drains the queue to the server when connectivity returns, with three terminal outcomes — ack, permanent error, transient retry.
Sync queue happy path: optimistic UI, queued mutation, then three terminal outcomes — 2xx ack, 4xx conflict, 5xx retry on the next sync event.

Best for:

  • Form submissions (surveys, orders)
  • Single-user data (personal notes, todos)
  • Tolerance for occasional conflicts

Implementation complexity:

Aspect Effort
Initial setup Medium
Feature additions Medium
Sync logic Medium (queue management)
Testing Medium

Key implementation concerns:

  • Idempotency: Server must handle duplicate submissions
  • Ordering: Queue processes FIFO, but network latency can reorder
  • Failure handling: Permanent failures need user notification
Typescript
async function processQueue(): Promise<void> {  const queue = await getSyncQueue()  for (const item of queue) {    try {      await sendToServer(item)      await removeFromQueue(item.id)    } catch (error) {      if (isPermanentError(error)) {        await markAsFailed(item.id)        notifyUser(`Failed to sync: ${item.entity}`)      } else {        await incrementRetry(item.id)        if (item.retries >= MAX_RETRIES) {          await markAsFailed(item.id)        }      }    }  }}

Trade-offs:

  • Handles common offline scenarios
  • Server-side conflict resolution
  • May lose changes on permanent failures
  • Doesn’t support real-time collaboration

Path 3: CRDT-Based Local-First

Architecture: Local CRDT state is authoritative. Peers sync directly or through relay server. No central source of truth.

Text
App → CRDT State (IndexedDB) ↔ Peer/Server ↔ Other Clients' CRDT State

Best for:

  • Collaborative editing (documents, whiteboards)
  • P2P applications
  • Extended offline with multiple editors

Implementation complexity:

Aspect Effort
Initial setup High
Feature additions High
Sync logic Very High (CRDT implementation)
Testing Very High

Library options (sizes from Bundlephobia and each project’s release notes; minified-then-gzipped where available):

Library Focus Approximate size Mature
Yjs Text/structured data ~90 KB min, ~27 KB gzip Yes
Automerge 2 JSON documents Rust core compiled to ~200 KB+ Wasm Yes
Liveblocks Real-time + CRDT SaaS (proprietary engine) Yes
ElectricSQL Postgres sync Tens of KB client + Postgres extension Emerging
Typescript
const doc = new Y.Doc()// Persist to IndexedDBconst persistence = new IndexeddbPersistence("my-doc", doc)// Sync with server/peers when onlineconst provider = new WebsocketProvider("wss://sync.example.com", "my-doc", doc)// Get shared typesconst text = doc.getText("content")const todos = doc.getArray("todos")// Changes automatically synctext.insert(0, "Hello")

Trade-offs:

  • True offline-first with guaranteed convergence
  • Supports P2P architecture
  • Complex implementation
  • High memory overhead
  • Eventual consistency only (no transactions)

Decision Framework

Decision tree for picking an offline strategy: cache-only PWA for short outages, sync queue for single-user mutations, OT or CRDT for multi-user editing depending on whether the system can rely on a single server or needs P2P.
Pick the offline strategy by asking three questions: how long offline, how many concurrent editors, and whether you can rely on a single server.

Real-World Implementations

Figma: Hybrid Multiplayer with Local Recovery

Challenge: Complex vector graphics with potentially millions of objects, multiple concurrent editors, must feel instantaneous.

Approach: Figma’s own engineering write-up is explicit that they use a custom system that is “in the same family of solutions as CRDTs (it’s similar to CRDTs but is not a CRDT)” — operations are applied through a centralized server that establishes total order, with last-writer-wins registers per property and tree-structure-aware reparenting rules. They explicitly rejected classical OT as too complex.

Offline behavior (per the official Figma offline help):

  • Changes to currently-loaded pages are queued in IndexedDB and replayed on reconnect.
  • Retention window is 30 days on Chrome / Firefox / Edge / Opera, 7 days on Safari (ITP).
  • You cannot open a file that wasn’t loaded before going offline; this is recovery for an open editing session, not a full local-first model.

Technical details:

  • Canvas, geometry, and the multiplayer engine are written in C++ and compiled to WebAssembly (Figma engineering); React handles only UI chrome.
  • Selective sync — only download what’s viewed, not entire project.
  • Background prefetch of likely-needed files.

Limitation: There is no “download for offline” mode; if a file hasn’t been opened recently, it isn’t available.

Notion: Block-Based CRDT

Challenge: Rich text documents with blocks (paragraphs, code, embeds), tables, and databases — with millions of users and fan-out collaboration.

Approach (per Notion’s “How we made Notion available offline”, shipped in Notion 2.53 on 2025-08-19):

  • Pages marked “Available offline” are migrated to a CRDT-backed data model in a local SQLite cache.
  • Local state is tracked in offline_page and offline_action tables with multiple “reasons a page is offline” so eviction is reference-counted.
  • Per-page sync watermark is reconciled on reconnect; CRDT semantics merge concurrent text edits automatically.

Technical details:

  • Desktop and mobile apps only — the web client does not yet have offline mode.
  • Database views download the first 50 rows of the first view for any database marked offline; remaining rows must be opened individually. As of 2026 this cap still holds across all plans.
  • Free plans manually toggle pages; paid plans also auto-download top favourites and most-recent pages.

Limitation: Non-text properties (select fields, relations, linked databases) cannot merge cleanly. When Notion can’t reconcile, it forks the page with a (Conflict) suffix instead of guessing.

Linear: Delta Sync

Challenge: Project management with issues, projects, and workflows. Must feel instant.

Approach (consolidated from Linear’s own “Scaling the sync engine” talks and the Linear-CTO-endorsed reverse-engineering write-up):

  • Bootstrap process downloads a normalized object pool into IndexedDB on first load.
  • WebSocket pushes incremental delta packets keyed by a monotonically increasing lastSyncId.
  • A MobX-managed in-memory object graph drives the UI; mutations are framed as transactions and queued in IndexedDB until acknowledged.
  • Server is the authority for total order — no peer-to-peer sync, no CRDT.

Technical details:

  • Sync ID increments with each server-side transaction; clients reconcile deltas since their last seen ID.
  • Optimistic updates with rollback on server rejection.
  • Not true offline-first — designed as a connectivity failsafe rather than a local-first system.

Trade-off accepted: Offline is “best effort” — clients can read cached state and queue mutations briefly, but edits require eventual connectivity. Simpler than CRDT, ships faster, gives up indefinite offline.

Excalidraw: Pseudo-P2P with localStorage

Challenge: Collaborative whiteboard with minimal backend.

Approach (per Excalidraw’s E2EE blog post and the P2P feature write-up):

  • Pseudo-P2P: a Socket.IO relay (excalidraw-room) brokers end-to-end encrypted messages between peers.
  • Local state lives in localStorage (keys excalidraw for elements, excalidraw-state for UI).
  • AES-GCM key is generated client-side and embedded in the URL fragment, which never reaches the server — the server only sees opaque ciphertext.
  • A custom reconciler resolves merge order between peers.

Technical details:

  • Self-hosting via the open-source excalidraw-room server is required for non-Plus collaboration.
  • Web Crypto API (window.crypto.subtle) is the encryption primitive, so a secure context is mandatory.
  • Room-based collaboration with shareable links; works fully offline for local edits.

Limitation: The reconciliation strategy keeps elements alive aggressively to avoid losing concurrent edits, so a peer that didn’t see a delete can reintroduce the element on reconnect. Trade-off for simplicity.

Browser Constraints Deep Dive

Storage quota lifecycle as a state machine: best-effort writes live until usage approaches the origin quota or the group cap (~80% of disk) hits and the browser evicts LRU origins; persistent storage is protected on Chromium and Firefox; Safari's ITP wipes script-writable storage after seven days of browser use without site interaction regardless of persistence.
Storage quota lifecycle: best-effort vs persistent, group-cap LRU eviction on Chromium and Firefox, and Safari's seven-day ITP wipe that ignores the persistence flag.

Storage Quota Management

Typescript
async function manageStorageQuota(): Promise<void> {  const { quota, usage } = await navigator.storage.estimate()  const usagePercent = (usage! / quota!) * 100  if (usagePercent > 80) {    // Proactive cleanup before hitting quota    await evictOldCache()  }  if (usagePercent > 95) {    // Critical: may start failing writes    await aggressiveCleanup()    notifyUser("Storage nearly full")  }}async function evictOldCache(): Promise<void> {  const cache = await caches.open("dynamic-cache")  const requests = await cache.keys()  // Sort by access time (stored in custom header or IndexedDB metadata)  const sorted = await sortByLastAccess(requests)  // Evict oldest 20%  const toEvict = sorted.slice(0, Math.floor(sorted.length * 0.2))  await Promise.all(toEvict.map((req) => cache.delete(req)))}

Quota exceeded handling: When quota is exceeded, IndexedDB and Cache API throw QuotaExceededError. Always wrap storage operations:

Typescript
async function safeWrite(key: string, value: unknown): Promise<boolean> {  try {    await writeToIndexedDB(key, value)    return true  } catch (error) {    if (error.name === "QuotaExceededError") {      await evictOldCache()      try {        await writeToIndexedDB(key, value)        return true      } catch {        notifyUser("Storage full. Some data may not be saved offline.")        return false      }    }    throw error  }}

Safari’s 7-Day Eviction

Safari’s ITP deletes all script-writable storage after 7 days without user interaction. Mitigation strategies:

  1. Prompt for PWA installation: Installed PWAs are exempt from 7-day limit
  2. Request persistent storage: Not supported in Safari, but doesn’t hurt
  3. Design for re-sync: Assume local data may disappear
  4. Track last interaction: Warn users approaching 7-day cliff
Typescript
const SAFARI_EVICTION_DAYS = 7function checkEvictionRisk(): { daysRemaining: number; atRisk: boolean } {  const lastInteraction = localStorage.getItem("lastInteraction")  if (!lastInteraction) {    localStorage.setItem("lastInteraction", Date.now().toString())    return { daysRemaining: SAFARI_EVICTION_DAYS, atRisk: false }  }  const daysSince = (Date.now() - parseInt(lastInteraction)) / (1000 * 60 * 60 * 24)  const daysRemaining = SAFARI_EVICTION_DAYS - daysSince  // Update interaction timestamp  localStorage.setItem("lastInteraction", Date.now().toString())  return {    daysRemaining: Math.max(0, daysRemaining),    atRisk: daysRemaining < 2,  }}

Cross-Browser Testing

Offline-first behavior varies significantly across browsers. Test matrix:

Scenario Chrome Firefox Safari
Quota exceeded QuotaExceededError QuotaExceededError QuotaExceededError
Persistent storage Auto-grant for PWAs User prompt Not supported
Background sync Supported Not supported Not supported
Service Worker + private mode Works Works Limited
IndexedDB in iframe Works Works Blocked (3rd party)

Common Pitfalls

1. Trusting navigator.onLine

The mistake: Using navigator.onLine to determine if sync should happen.

Typescript
// Don't do thisif (navigator.onLine) {  await syncData()}

Why it fails: navigator.onLine only checks for network interface, not internet connectivity. LAN without internet, captive portals, and firewalls all report online: true.

The fix: Use actual fetch with timeout as connectivity check:

Typescript
async function canReachServer(): Promise<boolean> {  try {    const controller = new AbortController()    const timeoutId = setTimeout(() => controller.abort(), 5000)    const response = await fetch("/api/health", {      method: "HEAD",      signal: controller.signal,      cache: "no-store",    })    clearTimeout(timeoutId)    return response.ok  } catch {    return false  }}

2. Ignoring IndexedDB Versioning

The mistake: Not handling schema upgrades properly.

Typescript
// Dangerous: no version handlingconst request = indexedDB.open("mydb")request.onsuccess = () => {  const db = request.result  const tx = db.transaction("users", "readwrite") // May not exist!}

Why it fails: If schema changes, existing users have old schema. Without proper onupgradeneeded, code accessing new object stores crashes.

The fix: Always increment version and handle migrations:

Typescript
const DB_VERSION = 3 // Increment with each schema changerequest.onupgradeneeded = (event) => {  const db = request.result  const oldVersion = event.oldVersion  // Migrate through each version  if (oldVersion < 1) {    db.createObjectStore("users", { keyPath: "id" })  }  if (oldVersion < 2) {    db.createObjectStore("settings", { keyPath: "key" })  }  if (oldVersion < 3) {    const users = request.transaction!.objectStore("users")    users.createIndex("by_email", "email", { unique: true })  }}

3. Service Worker Update Races

The mistake: Using skipWaiting() without considering page code compatibility.

Why it fails: Old page JavaScript + new Service Worker can have API mismatches. Cached responses may not match expected format.

The fix: Either reload the page after Service Worker update, or ensure backward compatibility:

Typescript
// In Service Workerself.addEventListener("message", (event) => {  if (event.data === "skipWaiting") {    self.skipWaiting()  }})// In pagenavigator.serviceWorker.addEventListener("controllerchange", () => {  // New SW took over, reload to ensure consistency  window.location.reload()})

4. Unbounded Storage Growth

The mistake: Caching without eviction policy.

Typescript
// Grows foreverconst cache = await caches.open("api-responses")cache.put(request, response) // Never cleaned up

Why it fails: Eventually hits quota, causing write failures. User experience degrades suddenly rather than gracefully.

The fix: Implement LRU or time-based eviction:

Typescript
const MAX_CACHE_ENTRIES = 100const MAX_CACHE_AGE_MS = 7 * 24 * 60 * 60 * 1000 // 7 daysasync function cacheWithEviction(request: Request, response: Response): Promise<void> {  const cache = await caches.open("api-responses")  const keys = await cache.keys()  // Evict if over limit  if (keys.length >= MAX_CACHE_ENTRIES) {    await cache.delete(keys[0]) // FIFO, or implement LRU  }  // Store with timestamp  const headers = new Headers(response.headers)  headers.set("x-cached-at", Date.now().toString())  const newResponse = new Response(response.body, {    status: response.status,    headers,  })  await cache.put(request, newResponse)}

5. Sync Conflict Denial

The mistake: Assuming conflicts won’t happen because “users don’t edit the same thing.”

Why it fails: Conflicts happen when the same user edits on multiple devices, when sync is delayed, or when retries duplicate operations.

The fix: Design for conflicts from the start:

  • Use idempotent operations with unique IDs
  • Implement conflict detection and resolution UI
  • Log conflicts for debugging
  • Test with simulated network partitions

Conclusion

Offline-first architecture inverts the traditional web assumption: data lives locally, sync is background, and network is optional. This enables responsive UX regardless of connectivity but introduces complexity in storage management, sync strategies, and conflict resolution.

Key architectural decisions:

Storage choice: IndexedDB for structured data with indexing needs, OPFS for binary files and performance-critical access, Cache API for HTTP responses. All share origin quota—monitor and manage proactively.

Sync strategy: LWW for simple, loss-tolerant cases. Sync queues for form-style interactions. OT for real-time collaboration with reliable connectivity. CRDTs for true offline-first with guaranteed convergence.

Browser reality: Safari’s 7-day eviction breaks long-term offline. Persistent storage is unreliable in Chromium and a no-op in Safari. navigator.onLine is useless for connectivity checks. Design for data loss and re-sync.

The platform is mature enough — Yjs, Automerge, Workbox, and ElectricSQL provide production-ready foundations. The complexity now lives in picking the right trade-off for your audience: how long users go offline, how many of them collaborate on the same record, and how much data loss is acceptable when the model can’t reconcile.

Appendix

Prerequisites

  • Browser storage APIs: localStorage, IndexedDB concepts
  • Service Workers: Basic lifecycle and fetch interception
  • Distributed systems basics: Consistency models, network partitions
  • Promises/async: Modern JavaScript async patterns

Terminology

  • CmRDT: Commutative/operation-based CRDT—replicate operations, apply in any order
  • CvRDT: Convergent/state-based CRDT—replicate state, merge with join function
  • ITP: Intelligent Tracking Prevention—Safari’s privacy feature that limits storage
  • LWW: Last-Write-Wins—conflict resolution where latest timestamp wins
  • OPFS: Origin Private File System—browser file system API
  • OT: Operational Transform—sync strategy that transforms concurrent operations
  • PWA: Progressive Web App—web app with offline capability via Service Worker
  • Tombstone: Marker for deleted item in CRDT—kept for ordering, never truly removed

Summary

  • Local-first data model: Application reads/writes to IndexedDB or OPFS immediately; network sync is asynchronous.
  • Service Workers: Intercept requests, implement caching strategies (cache-first, network-first, stale-while-revalidate), enable background sync.
  • Storage constraints: Quotas are mostly disk-percentage in Chrome / Firefox / Safari today; Safari evicts script-writable storage after seven days of browser use without interaction; persistent storage helps but isn’t guaranteed and is a no-op in Safari.
  • Conflict resolution: LWW loses data; OT requires a server; CRDTs guarantee convergence but are complex and can still interleave under multi-replica edits — choose based on offline duration and collaboration needs.
  • Production patterns: Figma runs a CRDT-inspired multiplayer engine with a centralized server and a 30-day session-recovery window; Notion ships a CRDT desktop/mobile offline mode capped at 50 rows per database view; Linear is delta sync over WebSocket with lastSyncId, not true offline-first; Excalidraw is pseudo-P2P with E2EE and localStorage.

References

Specifications and standards (tier 1):

Vendor and platform docs (tier 2):

Research papers (tier 3):

Production write-ups (tier 5):

Library docs (tier 5):