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.
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
- Background Sync API Implementation — scheduling guarantees, the
syncevent lifecycle, and cross-browser fallbacks. - Service Worker Caching Strategies — request interception, cache-first vs network-first, and stale-while-revalidate.
- Conflict Resolution Algorithms — LWW, vector clocks, and CRDT convergence for divergent offline state.
- Optimistic UI Updates & Rollback — instant local writes with deterministic rollback of rejected mutations.
- Storage Security, Encryption & Privacy — protecting the queued and cached data this guide makes durable.