Skip to content

frontendcache/offline-sync-lab

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

offline-sync-lab

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.


The problem

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.


Install

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 declarations

Then import the built library from ./dist:

import { createSyncEngine, MutationQueue } from "./dist/index.js";

Run the demo

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.


Library usage

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 three

Persisting the queue

The 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 session

Any store that can round-trip an array — localStorage, sessionStorage, IndexedDB, SQLite, a file — fits the three-method StorageAdapter shape (load / save / clear).


API reference

createSyncEngine(options)

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 stored Mutation.
  • 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 underlying MutationQueue and raw emitter.

Lifecycle events

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 }

MutationQueue

enqueue, all, sendable, get, setStatus, remove, replaceAll, clear, hydrate, plus size and isHydrated. Persistence is delegated to a StorageAdapter (MemoryStorageAdapter, LocalStorageAdapter, or your own).

Standalone helpers

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.


Design notes

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.


Development

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/)

Related reading

Deep-dive articles covering the cache patterns this library implements:


License

MIT — see LICENSE. Copyright (c) 2026 frontendcache.

About

Offline-first sync layer: queue mutations while offline, reconcile on reconnect, and dedupe concurrent writes — with a framework-free live demo of the sync lifecycle.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors