A framework-agnostic, offline-first sync toolkit with zero runtime dependencies. It gives you a durable mutation queue, pluggable conflict resolution, and reconnect-aware retries with exponential backoff — the plumbing every offline-capable app needs, without tying you to a framework, a storage engine, or a network client.
- Durable mutation queue — enqueue create/update/delete (or any custom op) locally, persist it, and flush it to your server when you are online.
- Pluggable conflict resolution —
lastWriteWins,clientWins,serverWins, or your ownmergeWith(fn). - Reconnect-aware retries — listens to online/offline (injectable) and retries with bounded exponential backoff + jitter.
- Swappable storage — in-memory,
localStorage, and IndexedDB adapters, or bring your own. - You own the network — implement a tiny
Transport; the kit orchestrates and never hardcodesfetch. - Testable by design — clock, randomness, timers, connectivity, and storage are all injectable, so behavior is deterministic in tests.
- Why
- Install
- Quick start
- Architecture
- API reference
- Storage adapters
- Conflict strategies
- Backoff
- Framework integration
- FAQ
- Further reading
- License
Offline-first apps all rediscover the same problems: local edits must survive a
reload, they must be replayed in order when connectivity returns, the server may
have moved on in the meantime (conflicts), and the network is flaky so retries
must be paced. offline-sync-kit packages those concerns into a small,
composable core so you can focus on your data model and UI instead of the
replay machinery.
It is deliberately unopinionated about how things are stored and sent: you plug in a storage adapter and a transport, and the kit runs the state machine.
This package is distributed through GitHub (it is not published to the npm registry). Install it straight from the repository:
npm install github:browser-storage-com/offline-sync-kitOr pin a tag or commit:
npm install github:browser-storage-com/offline-sync-kit#v0.1.0Requires Node.js 18+ for the tooling. The library itself is pure ESM and runs in modern browsers and any ESM-capable runtime.
import {
createSyncEngine,
IndexedDBAdapter,
createTransport,
browserOnlineEvents,
} from "offline-sync-kit";
// 1. Tell the kit how to reach your server. You return one result per mutation.
const transport = createTransport(async (mutation) => {
const res = await fetch("/api/sync", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(mutation),
});
if (res.status === 409) {
// The server has a newer version — report a conflict with its current value.
return { id: mutation.id, status: "conflict", remote: await res.json() };
}
if (!res.ok) {
return { id: mutation.id, status: "error", error: res.statusText };
}
return { id: mutation.id, status: "ok", data: await res.json() };
});
// 2. Create the engine.
const engine = createSyncEngine({
adapter: new IndexedDBAdapter(),
transport,
onlineEvents: browserOnlineEvents(),
});
// 3. React to progress (optional).
engine.on("synced", () => console.log("all local changes are on the server"));
engine.on("conflict", ({ resolved }) => console.log("resolved conflict", resolved));
// 4. Start background syncing, then enqueue optimistically.
engine.start();
await engine.enqueue({
type: "todo.create",
payload: { id: "t1", text: "Buy milk", done: false },
});The mutation is persisted immediately. If you are online it is pushed right
away; if not, it waits in the queue and is flushed automatically the moment the
online event fires.
There are runnable examples in the examples/ directory:
todo-sync.mjs— an offline editing session that drains to a simulated server on reconnect.conflict-merge.mjs— resolving a server conflict with a custom field-level merge.
The data flow is a small pipeline:
enqueue ──► [ mutation queue ] ──► [ transport.push ] ──► server
(storage adapter) │
▲ ├─ ok ─► status: done
│ ├─ conflict ─► [ conflict strategy ] ─► re-queue
└──────────────────────┴─ error ─► [ backoff ] ─► retry / failed
- Queue — every mutation is a durable record with an
id,type,payload,timestamp,version,attempts, andstatus. The queue owns ordering (FIFO by timestamp) and delegates persistence to a storage adapter. - Transport — you implement
push(mutations)and return one result per mutation (ok/conflict/error). This is the only place the network lives. - Conflict resolution — when the server reports a
conflict, the configured strategy reconciles the local and remote values; the resolved value is re-queued to be pushed again. - Backoff — when a push errors, the mutation is retried. The engine paces
retries with exponential backoff + jitter and gives up after a bounded number
of attempts, marking the mutation
failed.
| Status | Meaning |
|---|---|
pending |
Waiting to be pushed (fresh, or retryable after an error). |
inflight |
Currently being pushed to the server. |
done |
Accepted by the server (terminal). |
failed |
Exhausted the retry budget (terminal; see retryFailed). |
Creates and returns a SyncEngine. Options:
| Option | Type | Default | Description |
|---|---|---|---|
adapter |
StorageAdapter |
— (required) | Where mutations are persisted. |
transport |
Transport |
— (required) | How mutations reach the server. |
conflictStrategy |
ConflictStrategy |
lastWriteWins |
Resolves server-reported conflicts. |
backoff |
(attempt: number) => number |
expBackoff({ jitter: 0.2 }) |
Maps a retry attempt to a delay in ms. |
maxAttempts |
number |
5 |
Push attempts before a mutation is marked failed. |
isOnline |
() => boolean |
navigator.onLine (or true) |
Current connectivity. |
onlineEvents |
OnlineEvents |
undefined |
Connectivity event source wired up by start(). |
now |
() => number |
Date.now |
Clock, for timestamps. |
random |
() => number |
seeded PRNG | Randomness threaded into the default backoff jitter. |
idFactory |
() => string |
crypto.randomUUID() if present |
Generates mutation ids. |
setTimeout |
(fn, ms) => TimerHandle |
global setTimeout |
Injectable timer for scheduling retries. |
clearTimeout |
(handle) => void |
global clearTimeout |
Injectable timer canceller. |
enqueue(input)→Promise<Mutation>— persist a new mutation and return the stored record.inputis{ type, payload, id?, timestamp?, version? }.flush()→Promise<FlushSummary>— push all pending mutations, applying conflict resolution and updating statuses. Returns{ pushed, conflicts, failed }. Concurrent calls share one operation. A no-op while offline.start()— wire up online/offline listeners and flush immediately if online. Auto-flushes on every reconnect.stop()— detach listeners and cancel any scheduled retry.on(event, handler)→() => void— subscribe; returns an unsubscribe function. See events below.getPending()→Promise<Mutation[]>— mutations still awaiting a push.getAll()→Promise<Mutation[]>— every stored mutation, any status.clear()→Promise<void>— remove all mutations and reset retry state.retryFailed()→Promise<number>— reset everyfailedmutation back topendingand return how many were reset.
on(event, handler) supports:
| Event | Payload | Fired when |
|---|---|---|
enqueued |
{ mutation } |
A mutation is persisted. |
flushed |
{ summary } |
A flush completes. |
conflict |
{ mutation, local, remote, resolved } |
A conflict is detected and resolved. |
synced |
{ at } |
The queue drains (no pending mutations remain). |
error |
{ mutation?, error } |
A mutation (or the whole batch) fails to push. |
statuschange |
{ mutation, from, to } |
A mutation transitions to a new status. |
Returns a pure (attempt: number) => number delay function. See
Backoff.
interface Transport {
push(mutations: Mutation[]): Promise<PushResult[]>;
}
interface PushResult {
id: string; // the mutation id this result refers to
status: "ok" | "conflict" | "error";
data?: unknown; // server-applied value on "ok"
remote?: unknown; // server's current value on "conflict"
base?: unknown; // common ancestor for 3-way merges
error?: string; // message on "error"
}Use createTransport(handler) if your API is one request per mutation; the kit
calls your handler once per mutation and collects the results.
All exported types — Mutation, MutationInput, MutationStatus,
StorageAdapter, Transport, PushResult, ConflictStrategy, OnlineEvents,
FlushSummary, SyncEventMap, and more — are available from the package root.
An adapter is any object implementing four async methods: getAll(),
put(mutation), delete(id), and clear(). Three are built in:
| Adapter | Backing store | Durable? | Best for |
|---|---|---|---|
MemoryAdapter |
Map in memory |
No | Tests, SSR, or when durability is handled elsewhere. |
LocalStorageAdapter |
localStorage (one JSON blob) |
Yes | Small queues and simple apps. |
IndexedDBAdapter |
IndexedDB object store | Yes | Production browser apps and larger queues. |
import {
MemoryAdapter,
LocalStorageAdapter,
IndexedDBAdapter,
} from "offline-sync-kit";
new MemoryAdapter();
new LocalStorageAdapter({ key: "myapp:sync" }); // storage defaults to localStorage
new IndexedDBAdapter({ dbName: "myapp", storeName: "mutations" });The IndexedDBAdapter accepts an injectable indexedDB factory, which is how
its test suite runs under Node using
fake-indexeddb:
import "fake-indexeddb/auto";
new IndexedDBAdapter({ indexedDB: globalThis.indexedDB });A conflict strategy has the signature (local, remote, base?) => resolved. When
your transport returns { status: "conflict", remote }, the engine calls the
strategy, emits a conflict event, and re-queues the resolved value to be
pushed again.
| Strategy | Behavior |
|---|---|
lastWriteWins |
Keeps the value with the greater clock (updatedAt → timestamp → version); ties favor local. |
clientWins |
Always keeps the local value. |
serverWins |
Always keeps the remote value. |
mergeWith(fn) |
Delegates to your (local, remote, base?) => resolved function. |
import { createSyncEngine, mergeWith } from "offline-sync-kit";
const engine = createSyncEngine({
adapter,
transport,
// Field-level merge: adopt the server's title, keep our body edit.
conflictStrategy: mergeWith((local, remote) => ({
...remote,
body: local.body,
})),
});There is also hasVersionConflict(expectedBaseVersion, remoteVersion), a small
detector you can use inside your transport to decide when to report a conflict.
expBackoff builds a pure delay function: min(max, base * factor ** attempt),
optionally spread by jitter. Jitter draws from an injectable random
function (defaulting to a seeded PRNG) — the kit never calls Math.random()
directly, so retries are reproducible in tests.
import { expBackoff } from "offline-sync-kit";
const delay = expBackoff({ base: 200, factor: 2, max: 30_000, jitter: 0.3 });
delay(0); // ~200ms (±30% jitter)
delay(1); // ~400ms
delay(2); // ~800ms| Config | Default | Meaning |
|---|---|---|
base |
100 |
Delay (ms) for attempt 0. |
factor |
2 |
Growth multiplier per attempt. |
max |
30_000 |
Upper bound (ms) on the delay. |
jitter |
0 |
Fraction [0, 1] of the delay to randomize (0 = none). |
random |
seeded | Randomness source in [0, 1). |
With jitter: 0 the function is fully deterministic. With jitter, a random
that returns 0.5 yields exactly the un-jittered delay, so the midpoint is
stable.
The kit is framework-agnostic — it never imports React, Vue, Svelte, or any runtime. Integration is the same everywhere: create one engine, subscribe to its events, and enqueue on user actions.
- Create the engine once at app startup (a module singleton, a context/
provider, or a store) and call
engine.start(). - Enqueue optimistically in your event handlers, and update your local UI state immediately. The queue guarantees the change is durable and will reach the server.
- Reflect progress by subscribing to
statuschange,synced,conflict, anderror, and mapping them into your framework's reactive state. - Roll back on terminal failure: listen for
errorwhere the mutation reachesfailed, and revert the corresponding optimistic UI change. - Clean up by calling
engine.stop()when tearing down (e.g. a component unmount in a single-engine SPA, or onbeforeunload).
Because isOnline, onlineEvents, now, random, and the timers are all
injectable, the same engine is trivial to drive in unit tests with an in-memory
adapter and a fake transport.
Does it work outside the browser?
Yes. The core has no DOM dependencies. Use the MemoryAdapter (or your own
adapter) and inject isOnline/onlineEvents. The localStorage and IndexedDB
adapters simply need those globals — or an injected equivalent.
How do optimistic updates and rollback work?
Enqueue the mutation and update your UI immediately. If it later reaches the
failed status, the error event lets you revert. See the further-reading link
on optimistic UI and rollback below.
What happens to a mutation that keeps failing?
It is retried with exponential backoff up to maxAttempts, then marked
failed (terminal). Call retryFailed() to move failed mutations back to
pending — for example after the user taps "retry".
Are conflicts detected for me?
Detection lives on your server/transport: return { status: "conflict", remote }
when the server's version is ahead. The kit then applies your chosen strategy.
hasVersionConflict helps you make that decision.
Is the queue ordered?
Yes — FIFO by timestamp (ties broken by id) for deterministic replay.
Does it batch requests?
flush() hands your transport.push the whole pending batch at once; whether
you send one request or many is up to your transport implementation.
Zero dependencies — really?
The runtime has no dependencies. typescript and fake-indexeddb are
dev-only.
Deeper background on the algorithms and patterns behind this kit:
- Conflict resolution algorithms — an overview of how offline systems reconcile divergent edits.
- Last-write-wins vs CRDT for offline notes — when a simple clock is enough and when you need a CRDT.
- Optimistic UI updates and rollback — applying changes locally and reverting them cleanly on failure.
- Handling network flakiness with exponential backoff — pacing retries so flaky networks recover instead of stampeding.
- Background Sync API implementation — deferring work to the browser's Background Sync API.
Maintained by the team behind Browser Storage & Offline-First State Persistence.
MIT © 2026 browser-storage.com