Version v0.1.0 Size 1.1 KB gzip Dependencies Zero dependencies
Why Tandem?
A local-first app keeps its records in IndexedDB but must still reach a server: batch bursts of edits into one upload, propagate deletions that a plain pull would resurrect, and reconcile two devices that edited the same record while offline. Tandem drives that round trip against any backend, tracking a per-record write counter so a push carries only what actually changed.
// Before
// Hand-rolled: debounce each save, POST the whole table, hope deletions survive.
let timer;
function onSave(record) {
clearTimeout(timer);
timer = setTimeout(() => fetch('/sync', { method: 'POST', body: JSON.stringify(allRecords()) }), 3000);
}
// After
import { createSync } from '@vielzeug/tandem';
const sync = createSync({ gateway, port, idleDelayMs: 3000 });
sync.changed(); // a local edit happened — the engine batches and pushes dirty records| Feature | Tandem | RxSync | PowerSync |
|---|---|---|---|
| Bundle size | 1.1 KB | ||
| Zero runtime dependencies | |||
Backend-agnostic (bring your port) | |||
Storage-agnostic (bring your gateway) | |||
| Tombstoned deletions + rev conflicts |
Use tandem when you already own a local store and a backend and want the sync scheduler — dirty tracking, batching, hide-flows, and conflict reconcile — without adopting a database or a server.
Consider PowerSync when you want a managed sync service with its own SQLite client and server component rather than a scheduler over your existing storage.
Installation
pnpm add @vielzeug/tandemnpm install @vielzeug/tandemyarn add @vielzeug/tandemQuick Start
Implement the two seams — a SyncPort for the server and a SyncGateway for local storage — then start the scheduler. Records are opaque to Tandem; it reads only id and rev, and every rev comparison is the engine's: the gateway upserts, validates, and tombstones.
import { createSync, type SyncGateway, type SyncPort } from '@vielzeug/tandem';
const port: SyncPort = {
pull: (since) => fetch(`/sync?since=${since ?? ''}`).then((r) => r.json()),
push: async (records, deletions) => {
const response = await fetch('/sync', { method: 'POST', body: JSON.stringify({ records, deletions }) });
if (!response.ok) throw new Error(await response.text()); // divergence → the engine pulls to reconcile
},
};
const gateway: SyncGateway = {
records: () => localRecords.map((record) => ({ entity: 'docs', record })),
pendingDeletions: () => readTombstones(),
applyRecords: (pulled) => upsert(pulled), // the engine pre-filters; you validate untrusted records
applyDeletions: (deletions) => removeLocal(deletions),
clearDeletions: (deletions) => dropTombstones(deletions),
loadState: () => readSyncState(),
saveState: (state) => writeSyncState(state),
};
const sync = createSync({ gateway, port });
sync.tap((event) => console.debug('sync', event)); // observe pushes, pulls, warnings
myStore.subscribe(() => sync.changed()); // a local write happened
// On account switch or teardown — after flushing this account's work:
await sync.flush();
sync.dispose();Features
createSync()— drives a port against a gateway with a serialized push queue.SyncPort— the server seam:pull(since)andpush(records, deletions).SyncGateway— the storage seam: upserts, tombstones, and the persisted baseline — the engine owns every rev comparison.revbaselines — a record is dirty exactly when its rev is above the last-synced one, across reloads.changed()— wire it to your write path; the engine debounces a burst into one push.flush()— a full cycle on demand: pull remote changes, then push dirty records; rejects on failure.- Hide-flows — flushes with
keepaliveonpagehide/hidden, runs a full cycle on return to the foreground. tap()— typedTandemEvents for pushes, pulls, invalid records, and warnings.- Lifecycle-owned —
dispose(),disposed,disposalSignal, and[Symbol.dispose].
Documentation
See Also
- Vault — the typed storage core most gateways wrap; its IndexedDB adapter is a natural
records()/applyRecords()backend. - Postmaster — the durable outbox half of offline sync; pair it with Tandem for at-least-once mutations alongside state sync.
- Sentinel — subscribable browser state; its network signal can gate when you call
flush(). - Ripple — signals and effects;
store.subscribe(() => sync.changed())is the usual wiring.