An offline-first sync layer that queues mutations while offline, reconciles them on reconnect, and dedupes concurrent writes — with a live demo of the whole lifecycle.
Optimistic UIs are easy to demo and hard to ship. The moment the network is unreliable, the naive version — fire a fetch on every click, hope it lands — starts losing writes, replaying stale ones, and double-applying the same edit. offline-sync-lab is a small, zero-dependency TypeScript library that models the durable path instead: every mutation goes into a persisted queue, redundant writes to the same record collapse before sending, and server responses are reconciled back into your cache with an explicit conflict strategy.
It ships with a self-contained, framework-free live demo so you can watch a mutation move through enqueue → queued (offline) → dedupe → flush → reconcile in real time.
Deep-dive guides on these patterns live at frontendcache.com.
Cache-layer code that only ever runs online is code that has never been tested. On a real network you hit:
- Mutations fired offline are lost on reload. The user checks off three todos in a tunnel, closes the tab, and the writes evaporate — they only ever existed in a component's memory.
- Writes double-apply on reconnect. A retry that "failed" actually succeeded server-side, so replaying it creates two records, or increments a counter twice.
- Racing writes to the same record clobber each other. The user toggles a checkbox five times while offline; a naive queue sends all five, resurrecting intermediate states nobody wanted, in whatever order they land.
- The server has moved on. By the time a queued edit arrives, a teammate already changed the record. Whose value wins — and does your cache even know a conflict happened?
Each of these is a different bug, and each needs a different mechanism: durable persistence, idempotent/collapsed sends, ordered draining, and explicit reconciliation. That's what this library is.
Not published to any registry — clone and build:
git clone https://github.com/frontendcache/offline-sync-lab.git
cd offline-sync-lab
npm install
npm run build # compiles src/ → dist/ with type declarationsThen import the built library from ./dist:
import { createSyncEngine, MutationQueue } from "./dist/index.js";npm run build # the demo imports the built library from ../dist
npm run demo # starts a tiny static server (no network installs)Open the printed URL (default http://localhost:5173/). Toggle Go offline, edit some todos, and watch them pile up in the pending queue. Toggle Go online and watch the queue dedupe, flush in order, and reconcile — every step is logged in the lifecycle panel. Flip Server rejects writes to see retry/backoff and a mutation left safely queued.
Everything is injectable, so the engine runs headless — no browser, no navigator.onLine, no timers you can't control. Here is the full offline → reconnect cycle in Node:
import { createSyncEngine, MutationQueue } from "offline-sync-lab"; // or "./dist/index.js"
// A cache your UI renders from; optimistic + reconciled writes both land here.
const cache = new Map<string, { title: string; done: boolean }>();
// Your real network call. Resolve to accept (optionally with authoritative
// `data`); throw to signal a transient failure that should be retried.
const transport = async (m) => {
const res = await fetch(`/api/todos/${m.entityKey}`, {
method: "PATCH",
body: JSON.stringify(m.payload),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { data: await res.json() }; // server's authoritative view
};
let online = true;
const engine = createSyncEngine({
queue: new MutationQueue(), // in-memory by default; swap in a persistent adapter
transport,
isOnline: () => online, // abstracted — no hard dependency on the browser
dedupe: true, // collapse same-entity writes (last-write-wins)
conflictStrategy: "serverWins", // reconcile server responses back into the cache
applyOptimistic: (m) => cache.set(m.entityKey, { ...cache.get(m.entityKey)!, ...m.payload }),
onSync: (e) => cache.set(e.mutation.entityKey, e.resolved), // adopt the reconciled value
});
// Go offline and make some edits — they queue instead of sending.
online = false;
await engine.setOnline(false);
await engine.enqueue({ type: "todo.update", entityKey: "todo:5", payload: { done: true } });
await engine.enqueue({ type: "todo.update", entityKey: "todo:5", payload: { done: false } });
await engine.enqueue({ type: "todo.update", entityKey: "todo:5", payload: { done: true } });
engine.queue.size; // 3 queued, nothing sent
// Reconnect: the three edits to todo:5 collapse to one, then flush and reconcile.
online = true;
await engine.setOnline(true);
engine.queue.size; // 0 — one request sent, not threeThe queue talks to a StorageAdapter, so the pending mutations can survive a reload. An in-memory adapter is the default; a localStorage one is built in (and drives the demo):
import { MutationQueue, LocalStorageAdapter } from "offline-sync-lab";
const queue = new MutationQueue({
storage: new LocalStorageAdapter(window.localStorage, "todos:queue"),
});
await queue.hydrate(); // restore anything queued in a previous sessionAny store that can round-trip an array — localStorage, sessionStorage, IndexedDB, SQLite, a file — fits the three-method StorageAdapter shape (load / save / clear).
| Option | Type | Default | Purpose |
|---|---|---|---|
queue |
MutationQueue |
— | The queue to drain (required). |
transport |
(m) => Promise<{ data? }> |
— | Sends one mutation; throw to retry (required). |
isOnline |
() => boolean |
navigator.onLine / true |
Connectivity source; drive it in tests. |
dedupe |
boolean | { merge } |
true |
Collapse same-dedupeKey writes before sending. |
retry |
RetryOptions |
5 attempts, 100 ms base, ×2 | Exponential backoff for transient failures. |
conflictStrategy |
"serverWins" | "clientWins" | "merge" |
"serverWins" |
How to reconcile server responses. |
merge |
(client, server) => S |
— | Required when conflictStrategy is "merge". |
applyOptimistic |
(m) => void |
— | Apply a mutation to your cache on enqueue. |
autoFlushOnReconnect |
boolean |
true |
Flush automatically on offline → online. |
sleep |
(ms) => Promise |
real timer | Injectable delay for deterministic backoff tests. |
onStatusChange / onSync / onConflict |
listeners | — | Convenience shorthands for engine.on(...). |
Returns a SyncEngine:
enqueue(input)— add a mutation ({ type, entityKey, payload, dedupeKey? }), apply it optimistically if configured, and auto-flush when online. Returns the storedMutation.flush()— dedupe, then drain the queue in FIFO order. Overlapping calls coalesce into the one in flight. Returns{ sent, failed, dropped, remaining }.setOnline(bool)— update connectivity; an offline → online transition triggers a flush.isOnline()/status()— read current state ({ online, syncing, queueSize }).on(event, listener)— subscribe; returns an unsubscribe function.queue/events— the underlyingMutationQueueand raw emitter.
| Event | Fires when | Payload |
|---|---|---|
statusChange |
online/syncing/queue size changes | EngineStatus |
enqueue |
a mutation is queued | { mutation } |
drop |
a mutation is deduped away | { mutation } |
mutationStatus |
a queued mutation changes status | { mutation } |
flushStart / flushComplete |
a flush begins / ends | { pending } / FlushResult |
conflict |
a server response is reconciled | Conflict |
sync |
a mutation lands and is reconciled | { mutation, resolved, conflict } |
error |
a send attempt fails | { mutation, error, willRetry } |
enqueue, all, sendable, get, setStatus, remove, replaceAll, clear, hydrate, plus size and isHydrated. Persistence is delegated to a StorageAdapter (MemoryStorageAdapter, LocalStorageAdapter, or your own).
dedupeMutations(mutations, { merge? }) and reconcile(mutation, serverData, strategy, merge?) are exported directly, so you can unit-test the collapsing and conflict logic in isolation from the engine.
Why a queue + dedupe instead of just retrying fetches? A bare "retry the fetch" loop conflates three separate concerns and gets all of them subtly wrong. It has no memory across reloads (retries live in a promise, not in storage), so a refresh loses in-flight work. It has no notion of which entity a write targets, so five toggles of the same checkbox become five requests that replay intermediate states. And it has no reconciliation step, so whatever the server returns is silently ignored. A queue makes the pending work durable and inspectable; deduping by dedupeKey makes redundant work collapse before it costs a round-trip; reconciliation makes the server's answer authoritative. See rolling back optimistic updates on error for the failure side of the same coin.
Coalescing rules. Mutations are grouped by dedupeKey (default `${type}:${entityKey}`). Each group collapses to its latest member (last-write-wins), or is folded with your merge function so partial field edits union instead of overwriting. The collapsed mutation keeps the earliest member's queue position, so ordering across different entities is preserved — a create still precedes an unrelated update. Only pending/failed mutations are eligible; anything in flight is never touched.
Why abstract isOnline? Because navigator.onLine is both a lie (it reports the NIC, not reachability) and untestable. Behind an injected isOnline() + setOnline(), the entire engine runs deterministically in Node — every test in test/ drives connectivity by hand, no mocking of globals.
Ordering under failure. When a mutation exhausts its retries, the flush stops rather than skipping ahead, so a later write can never overtake an earlier one that hasn't landed. The failed mutation stays queued and is retried on the next flush.
npm install
npm test # node:test under tsx — 31 headless tests
npm run typecheck # tsc --noEmit (strict)
npm run build # tsc → dist/
npm run demo # static server for demo/ (imports the built dist/)Deep-dive articles covering the cache patterns this library implements:
- Cache invalidation & server synchronization — the pillar this tool sits under.
- Syncing offline mutations on reconnect — the exact flow the engine automates.
- Persisting React Query cache to IndexedDB — durable persistence for the queue and cache.
- Updating cache from WebSocket events — reconciling server-pushed changes, the realtime counterpart to reconnect sync.
MIT — see LICENSE. Copyright (c) 2026 frontendcache.