Offline Sync Strategies & Background Workflows

Building resilient offline-first applications requires moving beyond simple caching into deterministic state persistence, deferred execution, and conflict-aware synchronization. This guide provides production-ready architectural patterns for implementing reliable offline sync, background processing, and state reconciliation in modern web applications. The strategies outlined here prioritize data integrity, explicit quota management, and graceful degradation across fragmented browser environments. It builds directly on the primitives covered in Browser Storage Fundamentals & Quotas and pairs with the durability and trust model laid out in Storage Security, Encryption & Privacy — because the data you queue offline is exactly the data you must also protect at rest.

Offline-first sync pipeline A diagram tracing a user mutation from the UI through an IndexedDB queue, a service worker, and a retry loop, into the server, with conflict resolution merging the response back. UI mutation optimistic write IndexedDB queue durable · pending Service worker background sync Retry + backoff jitter · ceiling Server source of merge Conflict resolve LWW · vector clock

The offline sync landscape

Offline support is not a single feature but a stack of cooperating mechanisms, each solving a different failure mode. Choosing the wrong primitive for a job — for example, leaning on localStorage for a write queue that must survive crashes — produces silent data loss that only surfaces in the field. The table below maps each layer of an offline-first system to the browser capability that backs it and its real cross-browser support, so you can scope an implementation before writing code.

Layer Browser capability Purpose Support reality
Connectivity sensing navigator.onLine + fetch probe Decide when to flush the queue Universal, but onLine lies behind captive portals
Durable write queue IndexedDB transactions Persist mutations across reloads and crashes Universal; quota behavior differs
Deferred execution Background Sync API Run a flush after connectivity returns, even if the tab closed Chromium only; needs a fallback
Request interception Service Worker fetch Queue or serve offline without touching app code Universal in secure contexts
Conflict reconciliation App logic (LWW, vector clocks, CRDTs) Merge divergent client and server state App-level; no native API
Bandwidth control CompressionStream, batching Shrink sync payloads on metered links CompressionStream baseline since 2023

The four guides linked throughout this page go deep on the load-bearing layers: deferred execution in Background Sync API Implementation, request interception in Service Worker Caching Strategies, reconciliation in Conflict Resolution Algorithms, and the UI half of the problem in Optimistic UI Updates & Rollback. This page is the architectural map that ties them together.

Architectural Foundations for Offline-First State Persistence

Establishing a resilient baseline for state management is critical when network connectivity is intermittent, degraded, or entirely unavailable. Offline-first architecture treats the local device as the primary data source, with the server acting as a synchronization target rather than the source of truth. Every read serves from local storage first, every write lands locally and synchronously from the user’s perspective, and the network becomes an asynchronous background concern. This inversion is what makes an app feel instant on a subway platform and what forces you to confront persistence, quotas, and reconciliation head-on.

Network State Detection & Sync Lifecycle

Relying solely on navigator.onLine is insufficient for production environments. The property only indicates whether the device has a network interface, not whether the backend is reachable or routing correctly. A robust sync lifecycle combines connectivity listeners with active health probes to trigger reconciliation only when meaningful network paths exist.

async function checkConnectivity(): Promise<boolean> {
  try {
    const res = await fetch('/api/ping', {
      method: 'HEAD',
      cache: 'no-store',
      signal: AbortSignal.timeout(3000),
    });
    return res.ok;
  } catch {
    return false;
  }
}

// Lifecycle integration
window.addEventListener('online', async () => {
  const isReachable = await checkConnectivity();
  if (isReachable) {
    dispatchEvent(new CustomEvent('app:sync-ready'));
  }
});

The app:sync-ready event becomes the single trigger that the rest of the system listens for, decoupling connectivity detection from the queue-flush logic. This indirection matters: you will eventually want to fire the same event from a periodic timer, a manual “retry now” button, and a service worker message, and a custom event lets all three paths converge on one flush routine.

Cross-Browser & Compatibility Notes: navigator.onLine enjoys universal support across modern browsers. Note that mobile browsers may report true while behind captive portals or proxy firewalls. Always pair state detection with a lightweight fetch probe to a known endpoint. The probe endpoint should return a tiny payload (ideally a 204 No Content) and must bypass the HTTP cache, or a cached 200 will make a dead network look alive.

Debugging Workflow: Use Chrome DevTools → Application → Service Workers → “Offline” checkbox to simulate disconnection. Monitor state transitions in the Console by filtering for app:sync-ready. Validate probe latency using the Network tab’s “Slow 3G” throttling profile to ensure timeout guards trigger correctly before sync queues are flushed.

Storage Quotas & Persistence Guarantees

Offline-first applications accumulate mutations, cached assets, and reconciliation logs locally. Without explicit quota management, browsers will silently evict data or throw QuotaExceededError during batch operations. Proactive storage estimation and persistence requests are mandatory for production workloads. The mechanics of how each engine allocates and reclaims space are covered in Storage Quotas & Eviction Policies; the rules below are the operational distillation.

async function verifyQuota(): Promise<boolean> {
  if (!navigator.storage?.estimate) return false;
  const { usage = 0, quota = 1 } = await navigator.storage.estimate();
  // Maintain 20% safety margin to prevent mid-transaction failures
  return usage < quota * 0.8;
}

// Request persistent storage for critical offline data
async function ensurePersistence(): Promise<boolean> {
  if (navigator.storage?.persist) {
    return await navigator.storage.persist();
  }
  return false;
}

Cross-Browser & Compatibility Notes: Storage policies vary significantly: Chrome/Edge allocate ~60% of available disk space for the temporary storage pool (shared across all origins), Firefox caps the group at ~20% of total disk, and Safari enforces a ~1 GB per-origin cap for non-installed origins. Safari may clear IndexedDB and CacheStorage after 7 days of inactivity unless the user explicitly adds the PWA to the home screen. Always call navigator.storage.persist() during onboarding for mission-critical apps. The persistence grant is sticky once awarded but is silently denied on many mobile contexts, so treat a false return as the expected case and design the queue to tolerate eviction rather than assume durability.

Debugging Workflow: Open DevTools → Application → Storage to monitor real-time usage. Force quota exhaustion by injecting large payloads into IndexedDB and observe QuotaExceededError in the console. Implement try/catch around IDBTransaction commits and log usage / quota ratios to telemetry to trigger proactive cache pruning before hard limits are reached. For a guided walkthrough of reproducing and diagnosing eviction in installed apps, see Debugging Storage Eviction in Progressive Web Apps.

Background Execution & Service Worker Integration

Leveraging the Service Worker lifecycle allows deferred tasks to execute outside the main thread, preserving UI responsiveness while ensuring mutations are eventually delivered. A service worker is the only context that can keep working after the tab that spawned it has closed, which is precisely what an offline queue needs: the user fills a form, loses signal, switches apps, and expects the data to arrive without ever returning to your tab.

Registering & Triggering Background Tasks

The Background Sync API enables the browser to defer network-dependent operations until connectivity is restored. Registration must be guarded by feature detection and paired with fallback mechanisms for unsupported environments. For a comprehensive breakdown of scheduling guarantees and event lifecycle management, refer to Background Sync API Implementation.

async function registerBackgroundSync(tag: string): Promise<void> {
  if (!('serviceWorker' in navigator) || !('SyncManager' in window)) {
    console.warn(
      'Background Sync unsupported. Falling back to main-thread polling.'
    );
    return;
  }

  try {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register(tag);
  } catch (err) {
    console.error('Sync registration failed:', err);
    // Fallback: schedule via visibilitychange + setInterval
  }
}

Background Sync registrations are coalesced by tag, so registering the same tag repeatedly while offline results in a single sync event when connectivity returns rather than a flood. This makes the tag a natural deduplication key: use one tag per logical queue (outbox, analytics, media-uploads) and let the browser collapse retries for you. The distinction between this one-off model and the recurring schedule offered by the Periodic Background Sync API is examined in Periodic Background Sync vs One-Off Sync.

Cross-Browser & Compatibility Notes: Chrome and Edge provide full Background Sync support. Firefox and Safari do not implement the API. Cross-browser parity requires a fallback strategy using setInterval combined with document.visibilitychange listeners, ensuring sync attempts only occur when the tab is active to conserve resources. Because the fallback only runs while a tab is open, the durable IndexedDB queue is what guarantees no mutation is lost on the browsers that lack the API — the queue is the contract, the sync event is merely an optimization.

Debugging Workflow: In DevTools → Application → Service Workers, inspect the “Sync” panel to view registered tags. Simulate offline-to-online transitions by toggling the network state and verify the sync event fires in the Service Worker console. A complete, field-tested implementation of this pattern for form data is walked through in Implementing Reliable Background Sync for Form Submissions.

Caching & Request Interception Patterns

Routing offline mutations through Service Worker interceptors maintains cache consistency while decoupling UI rendering from network availability. When combined with Service Worker Caching Strategies, you can implement stale-while-revalidate patterns that don’t compromise sync integrity.

self.addEventListener('fetch', (event) => {
  // Intercept mutations for offline queuing
  if (event.request.method === 'POST' || event.request.method === 'PUT') {
    event.respondWith(queueMutation(event.request));
    return;
  }

  // Standard GET caching strategy
  event.respondWith(
    caches.match(event.request).then((cached) => {
      const networkFetch = fetch(event.request).catch(() => cached);
      return cached || networkFetch;
    })
  );
});

The read path and the write path demand opposite default strategies. Reads favor serving from cache for instant rendering and revalidating in the background; writes must never be served from cache and must be durably queued before the synthetic response resolves. The choice between a cache-first and a network-first read strategy is itself a tradeoff between freshness and resilience, dissected in Stale-While-Revalidate vs Network-First.

Debugging Workflow: Enable “Preserve Log” in the Console and filter for fetch events. Verify that intercepted POST requests are cloned before being passed to IndexedDB (requests can only be consumed once). Use the Network tab to confirm that intercepted requests show (from service worker) and that fallback caches serve valid responses during offline states. Forgetting to call request.clone() before reading the body is the single most common bug here, surfacing as an empty queued payload that looks correct in the network panel but is missing its body on flush.

Operation Queue & Retry Architecture

Fault-tolerant queues guarantee eventual consistency by persisting deferred mutations locally and executing them with deterministic retry logic. The queue is the heart of an offline-first system: if it is correct, the rest of the architecture degrades gracefully; if it loses or duplicates entries, no amount of clever conflict resolution can recover the lost intent.

IndexedDB-Backed Queue Design

In-memory arrays lose state on navigation or crashes. An IndexedDB-backed queue ensures atomic writes, transactional safety, and persistence across sessions. The transactional semantics that make this reliable — and the auto-commit pitfalls that quietly break it — are covered in depth in IndexedDB Transaction Management.

async function enqueueOperation(
  db: IDBDatabase,
  op: Record<string, unknown>
): Promise<void> {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('queue', 'readwrite');
    const store = tx.objectStore('queue');

    tx.oncomplete = () => resolve();
    tx.onerror = () => {
      if (tx.error?.name === 'QuotaExceededError') {
        pruneOldestEntries(db).catch(console.error);
      }
      reject(tx.error);
    };

    store.add({
      ...op,
      id: crypto.randomUUID(),
      status: 'pending',
      createdAt: Date.now(),
      retryCount: 0,
    });
  });
}

Assigning each operation a crypto.randomUUID() client-side is not cosmetic — it is the idempotency key that lets the server safely deduplicate a mutation that was sent, succeeded, but whose acknowledgement was lost when the connection dropped before the client could mark it done. Without a stable client-generated id, a retry after a lost ack creates a duplicate record on the server. Resolve the transaction on oncomplete, never on the request’s onsuccess: the latter fires before the write is durably committed, and a crash in that window loses the entry.

Cross-Browser & Compatibility Notes: IndexedDB is universally supported but behaves differently under quota pressure. Chrome throws QuotaExceededError synchronously during add(), while Safari may surface it during transaction commit. Always wrap operations in explicit error handlers on both the request and the transaction. Safari’s older WebKit builds also have a history of auto-committing transactions across await boundaries, so keep the enqueue transaction free of any asynchronous gaps between opening it and issuing the add().

Exponential Backoff & Fallback Execution

Aggressive retry loops overwhelm servers and drain client batteries. Implementing jitter-based exponential backoff prevents thundering herd scenarios and gracefully handles 429 Too Many Requests and 5xx server errors.

async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  attempts = 3,
  baseDelay = 1000
): Promise<T> {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (e) {
      if (i === attempts - 1) throw e;

      const jitter = Math.random() * 500;
      const delay = Math.pow(2, i) * baseDelay + jitter;
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
  throw new Error('Max retries exceeded');
}

Jitter is the part most implementations omit and the part that matters most at scale. When a server recovers from an outage, every offline client that queued work during the downtime tries to flush at the same instant; without randomized delay they synchronize into a self-inflicted denial-of-service. A delay ceiling (capping Math.pow(2, i) * baseDelay at, say, 30 seconds) prevents a long-lived background queue from drifting into hour-long retry intervals. The interplay between backoff, network flakiness, and the queue’s retry counter is explored fully in Handling Network Flakiness with Exponential Backoff.

Cross-Browser & Compatibility Notes: Background tabs may throttle setTimeout to 1000 ms+ in Chromium and Safari. Always clear pending timeouts on visibilitychange or pagehide to prevent memory leaks and duplicate execution when the tab resumes. Because of this throttling, a backoff schedule computed in a backgrounded tab will run later than its nominal delay — another reason to prefer the service worker’s sync event over a main-thread timer for the authoritative flush.

Optimistic Updates & Rollback

A durable queue makes data eventually consistent, but users experience the application now. Optimistic UI applies a mutation to the local view and the local store immediately, before the server has confirmed anything, so the interface never blocks on the network. The cost is that some of those optimistic writes will eventually be rejected — a stale version, a validation failure, a permission change — and the UI must roll back cleanly without leaving the user staring at a phantom record.

interface OptimisticEntry<T> {
  id: string;
  previous: T | null; // snapshot for rollback
  optimistic: T;
}

async function applyOptimistic<T>(
  store: Map<string, T>,
  entry: OptimisticEntry<T>,
  commit: () => Promise<void>
): Promise<void> {
  store.set(entry.id, entry.optimistic); // reflect immediately
  try {
    await commit(); // background sync against the server
  } catch {
    // Server rejected — restore the pre-mutation snapshot
    if (entry.previous === null) store.delete(entry.id);
    else store.set(entry.id, entry.previous);
    dispatchEvent(new CustomEvent('ui:rollback', { detail: entry.id }));
  }
}

The discipline that makes rollback reliable is capturing a snapshot of the prior state before mutating, so the failure path is a deterministic restore rather than a guess. When the optimistic record was a create rather than an update, the rollback is a delete; when it was an update, it is a restore of the captured previous value. Coordinating these snapshots with the durable IndexedDB queue — so a rollback also removes or amends the corresponding queued mutation — is the focus of Optimistic UI Updates & Rollback, with a transaction-level treatment in Rolling Back Failed Optimistic Updates in IndexedDB.

Conflict Resolution & Data Reconciliation

When clients operate offline, state diverges from the server. Reconciliation strategies must merge changes deterministically without data corruption or silent overwrites. The right strategy depends entirely on the data: a counter, a text document, and a calendar event each have different correct-merge semantics, and there is no universal algorithm.

Timestamp vs. Vector Clock Approaches

Last-Write-Wins (LWW) is simple but vulnerable to clock skew. Vector clocks or Lamport timestamps provide causal ordering, ensuring operations are applied in the correct sequence regardless of local device time. A thorough evaluation of deterministic merging techniques is available in Conflict Resolution Algorithms.

interface SyncRecord {
  id: string;
  data: Record<string, unknown>;
  updatedAt: number;
  version: number;
}

function resolveConflict(local: SyncRecord, remote: SyncRecord): SyncRecord {
  // Prefer server-authoritative timestamps for non-critical data
  if (local.updatedAt > remote.updatedAt) return local;
  return remote;
}

LWW is the right default for independent fields where the last edit genuinely supersedes earlier ones — a user’s display name, a theme preference. It is the wrong default for any value derived by accumulation or for fields edited concurrently on multiple devices, where it silently discards one writer’s work. Vector clocks trade storage and complexity for the ability to detect a concurrent edit rather than blindly pick a winner, letting you surface a real merge UI. The full mechanism, including how to compact the clock as participants come and go, is implemented in Implementing Vector Clocks for Offline Sync.

Cross-Browser & Compatibility Notes: Client-side Date.now() is unreliable due to OS clock drift, NTP adjustments, and user manipulation. For financial, healthcare, or compliance-critical data, always defer to server-authoritative timestamps or implement logical clocks. Browser performance.now() is monotonic but not synchronized across devices, so it can order events within a single session but never across them.

CRDTs & Merge Strategies for Complex State

For collaborative editing or deeply nested state, traditional diffing fails. Conflict-Free Replicated Data Types (CRDTs) guarantee mathematical convergence across distributed nodes: any two replicas that have seen the same set of operations arrive at the same state, regardless of the order in which those operations were delivered. This property is what makes real-time collaborative editors tolerate offline edits and out-of-order delivery without a central coordinator.

// Sketch of a CRDT-style update broadcast. A library encodes each local
// change into a commutative, idempotent update that any peer can merge.
function onLocalChange(update: Uint8Array): void {
  broadcastToServer(update).catch(() => {
    // Queue the update for retry if offline; CRDT merges are order-independent,
    // so a deferred update converges identically once it is finally delivered.
    enqueueOfflineUpdate(update);
  });
}

Production CRDT runtimes are available as open-source JavaScript libraries — the Yjs and Automerge projects are the two most widely deployed — and both expose binary update encodings designed to be queued and replayed exactly like the operations above. They rely on modern JavaScript features and carry real memory overhead, because correctness depends on retaining metadata (tombstones) for deleted content. Memory scales with operation history, so implement tombstone pruning and bound the history depth to prevent IndexedDB bloat, and monitor heap usage in production on all target browsers. The decision of whether a humble LWW field is sufficient or a full CRDT is warranted is worked through concretely in Last-Write-Wins vs CRDT for Offline Notes.

Payload Optimization & Network Efficiency

Minimizing bandwidth consumption and latency during sync windows is critical for mobile users and metered connections. A queue that has been offline for hours can hold a large backlog; flushing it naively over a metered link wastes the user’s data allowance and lengthens the window in which a sync can be interrupted.

Delta Encoding & Compression Techniques

Transmitting full document payloads wastes bandwidth and increases collision probability. Delta encoding transmits only changed fields. The native CompressionStream API (Baseline since 2023 — Chromium 80+, Firefox 113+, Safari 16.4+) enables client-side gzip/deflate without third-party dependencies:

function generateDelta(
  original: Record<string, unknown>,
  modified: Record<string, unknown>
): Record<string, unknown> {
  return Object.keys(modified).reduce(
    (acc, key) => {
      if (original[key] !== modified[key]) {
        acc[key] = modified[key];
      }
      return acc;
    },
    {} as Record<string, unknown>
  );
}

// Compress before transmission using the native CompressionStream API
async function compressPayload(data: string): Promise<Uint8Array> {
  const stream = new Blob([data]).stream();
  const compressed = stream.pipeThrough(new CompressionStream('gzip'));
  return new Response(compressed)
    .arrayBuffer()
    .then((buf) => new Uint8Array(buf));
}

Because CompressionStream is now baseline across the modern engines, most applications no longer need a bundled compression dependency at all. For the small share of older browsers that remain, the established pure-JavaScript deflate implementations — the pako and fflate libraries are the common choices — provide a drop-in fallback, but feature-detect CompressionStream first and prefer the native path so you ship no extra bytes on the majority of sessions. Delta encoding also reduces collision probability: sending only the fields a client actually changed means two clients editing different fields of the same record no longer conflict at all.

Batch Processing & Throttling

Individual HTTP requests per queued operation create excessive overhead and trigger server rate limits. Group operations into bounded batches to reduce connection establishment costs and enable atomic server-side processing.

async function flushBatch(
  db: IDBDatabase,
  storeName: string,
  batchSize = 10
): Promise<void> {
  const items = await new Promise<Record<string, unknown>[]>((resolve, reject) => {
    const tx = db.transaction(storeName, 'readonly');
    const req = tx.objectStore(storeName).getAll(null, batchSize);
    req.onsuccess = () => resolve(req.result as Record<string, unknown>[]);
    req.onerror = () => reject(req.error);
  });

  if (items.length === 0) return;

  const controller = new AbortController();
  const res = await fetch('/api/sync/batch', {
    method: 'POST',
    body: JSON.stringify(items),
    headers: { 'Content-Type': 'application/json' },
    signal: controller.signal,
  });

  if (!res.ok) throw new Error(`Batch failed: ${res.status}`);

  // Remove successfully synced items
  await new Promise<void>((resolve, reject) => {
    const tx = db.transaction(storeName, 'readwrite');
    const store = tx.objectStore(storeName);
    items.forEach((item) => store.delete(item.id as IDBValidKey));
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

A batch endpoint should be designed to report per-item results, not just an overall status, so a single bad operation in a batch of ten does not block the other nine. The deletion of synced items happens in a separate transaction after the network call returns, because IndexedDB transactions cannot stay open across an await on a network request — the engine auto-commits them the moment the microtask queue drains. Reading the batch, awaiting the server, and then deleting in a fresh transaction is the correct shape; trying to hold one transaction across the fetch is the most common cause of “my queue never clears” bugs.

Production Hardening & Observability

Offline sync introduces failure modes that don’t exist in always-online architectures. Production hardening requires explicit error boundaries, graceful degradation paths, and comprehensive telemetry. The goal is that a wiped store, a permanently rejected mutation, or a quota wall degrades into a clearly communicated local-only mode rather than a crash or silent data loss.

Error Boundaries & Graceful Degradation

When sync fails permanently or storage limits are reached, the UI must transition to a local-only mode with explicit user warnings rather than crashing or silently dropping data.

async function executeSyncCycle(): Promise<void> {
  try {
    await syncEngine.flush();
    dispatchEvent(new CustomEvent('sync:complete'));
  } catch (err) {
    const errorType = err instanceof DOMException ? err.name : 'Unknown';

    if (errorType === 'QuotaExceededError') {
      showOfflineBanner('Storage limit reached. Clear cache to resume sync.');
    } else if (errorType === 'NetworkError') {
      showOfflineBanner('Sync paused. Data stored locally.');
    } else if (errorType === 'AbortError') {
      // Expected during tab close or network drop
      return;
    } else {
      showOfflineBanner('Sync failed. Retrying in background...');
      await scheduleFallbackRetry();
    }
  }
}

Distinguishing an AbortError (a benign consequence of the user closing the tab mid-flush) from a genuine failure is what keeps your telemetry honest and your banners from crying wolf. A mutation that the server rejects with a 4xx validation error must never be retried — it will fail forever and clog the queue — so move it to a dead-letter store and surface it to the user, distinct from a transient 5xx that should back off and retry.

Telemetry & Sync Health Monitoring

Tracking queue depth, retry rates, and payload sizes enables proactive intervention and capacity planning.

window.addEventListener('sync:attempt', (e: CustomEvent) => {
  const { queueSize, duration, success } = e.detail;

  analytics.track('sync_attempt', {
    queueLength: queueSize,
    latencyMs: duration,
    successRate: success ? 1 : 0,
    userAgent: navigator.userAgent,
  });
});

// PerformanceObserver for precise timing
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name === 'sync-flush') {
      console.log(`TTS: ${entry.duration.toFixed(2)}ms`);
    }
  }
});
observer.observe({ entryTypes: ['measure'] });

Queue depth is the leading indicator that matters most: a steadily growing queue across your fleet means a server-side rejection or an outage is silently piling up client work that will eventually hit quota walls. Retry rate is the second: a sustained climb above roughly 15% signals a systemic problem rather than ordinary network noise.

Debugging Workflow: Instrument custom performance.mark() and performance.measure() calls around sync phases. Set up alerts for queue depth exceeding thresholds (>50 pending ops) or retry rates surpassing 15% over a 5-minute window.

Quick reference

Concern Recommended approach Anti-pattern to avoid
Connectivity navigator.onLine + active fetch probe Trusting onLine alone behind captive portals
Write durability IndexedDB queue, resolve on oncomplete In-memory array; resolving on onsuccess
Deferred flush Background Sync tag + timer fallback Assuming the sync event exists everywhere
Retry Exponential backoff with jitter and a ceiling Tight retry loop, no jitter
Idempotency Client-generated UUID per operation Server-assigned id only
Conflict merge LWW for independent fields, CRDT/clocks for concurrent edits LWW on accumulating or co-edited data
Payload Delta + native CompressionStream, batch Full-document PUTs, one request per op
Failure handling Dead-letter 4xx, back off 5xx Retrying validation failures forever

Frequently Asked Questions

Why isn't navigator.onLine enough to know I'm connected?

navigator.onLine only reports whether the device has an active network interface, not whether your backend is reachable. A phone on hotel Wi-Fi behind a captive portal reports true while every request fails. Pair the flag with a lightweight, cache-busting fetch probe to a known endpoint, and gate your queue flush on the probe result rather than the flag alone.

Should I queue offline writes in localStorage or IndexedDB?

IndexedDB. A write queue must survive crashes and reloads with transactional guarantees, and it can hold far more than the few megabytes localStorage offers. Resolve each enqueue on the transaction’s oncomplete event, never on the request’s onsuccess, so a crash in the commit window cannot lose the entry. See IndexedDB Transaction Management for the transactional details.

What do I do when Background Sync isn't supported?

Firefox and Safari do not implement the Background Sync API, so the durable IndexedDB queue must be the source of truth and the sync event merely an optimization. Fall back to a main-thread flush triggered on the online event and a visibilitychange-guarded timer. No mutation is lost because the queue persists regardless of whether a background sync ever fires. The full pattern is in Background Sync API Implementation.

How do I prevent the same operation from being applied twice?

Assign every queued operation a stable crypto.randomUUID() on the client and treat it as an idempotency key the server honors. If a mutation succeeds but its acknowledgement is lost, the retry carries the same id and the server deduplicates instead of creating a duplicate record. This is essential whenever a flush can be interrupted between server commit and client cleanup.

When is Last-Write-Wins safe, and when do I need a CRDT?

Last-Write-Wins is safe for independent fields where the latest edit genuinely supersedes earlier ones, such as a display name or a theme setting. It silently discards work for values that accumulate or are edited concurrently across devices. For collaborative documents or co-edited records, use vector clocks to detect concurrency or a CRDT to converge automatically. The tradeoff is examined in Last-Write-Wins vs CRDT for Offline Notes.

Related