Skip to content

browser-storage-com/offline-sync-kit

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

offline-sync-kit

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 resolutionlastWriteWins, clientWins, serverWins, or your own mergeWith(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 hardcodes fetch.
  • Testable by design — clock, randomness, timers, connectivity, and storage are all injectable, so behavior is deterministic in tests.

Table of contents

Why

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.

Install

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-kit

Or pin a tag or commit:

npm install github:browser-storage-com/offline-sync-kit#v0.1.0

Requires Node.js 18+ for the tooling. The library itself is pure ESM and runs in modern browsers and any ESM-capable runtime.

Quick start

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.

Architecture

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, and status. 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.

Mutation lifecycle

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

API reference

createSyncEngine(options)

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.

Engine methods

  • enqueue(input)Promise<Mutation> — persist a new mutation and return the stored record. input is { 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 every failed mutation back to pending and return how many were reset.

Events

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.

expBackoff(config)

Returns a pure (attempt: number) => number delay function. See Backoff.

Transport contract

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.

Storage adapters

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 });

Conflict strategies

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 (updatedAttimestampversion); 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.

Backoff

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.

Framework integration

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, and error, and mapping them into your framework's reactive state.
  • Roll back on terminal failure: listen for error where the mutation reaches failed, 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 on beforeunload).

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.

FAQ

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.

Further reading

Deeper background on the algorithms and patterns behind this kit:

Maintained by the team behind Browser Storage & Offline-First State Persistence.

License

MIT © 2026 browser-storage.com

About

Framework-agnostic offline-first sync toolkit — durable mutation queue, conflict resolution strategies, and background re-sync with exponential backoff.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors