Quickstart
A local database that works offline and converges with your other devices when it can.
npm install tangentfeed
import { openSpace, broadcast } from "tangentfeed"; const db = await openSpace({ space: "kitchen-42", transports: [broadcast()], }); const id = await db.insert("tasks", { title: "Order rice bran oil", done: false }); await db.update("tasks", id, { done: true }); db.subscribe(async () => { render(await db.list("tasks")); });
Open that page in two tabs and they sync. Add a WebRTC transport and it syncs across devices. Nothing else in your code changes.
Mental model
Five ideas cover almost everything. Reading them once will save you from surprises later.
A space is the unit of replication
A space is a named set of tables. Peers only sync with peers in the same space. One user's data, one space, is the usual mapping.
Every write is an operation
Writes append to a log rather than overwriting anything. The tables you read are a cache materialized from that log, which is why a delete writes a tombstone operation instead of removing rows.
The cell is the unit of conflict
Conflicts are resolved per (table, row, column). Two devices editing
the title and the status of the same row concurrently both keep their edit. Two
devices editing the same field means the later stamp wins.
Clocks are logical, not wall clocks
Each operation carries a hybrid logical clock stamp. A device whose clock is an hour slow still sorts its writes correctly relative to everything it has seen, because receiving an operation advances the local clock past it.
Convergence does not depend on delivery
Merging is commutative, associative, and idempotent. Messages can arrive out of order, twice, or much later, and every replica still reaches identical state. This is why transports are allowed to be unreliable.
There is no server that decides anything. No replica is authoritative, no write needs acknowledgement to be durable, and no read waits for the network.
Installation
The tangentfeed package pulls in the engine, IndexedDB storage,
encryption, and both browser transports. Install component packages directly if
you want a narrower dependency footprint.
npm install tangentfeed
npm install tangentfeed @tangentfeed/adapter-sqlite better-sqlite3
npm install tangentfeed @tangentfeed/react
Every package ships ES modules with TypeScript declarations. There is no build step or bundler plugin to configure.
Reading and writing
Writing
Rows are identified by a client-generated ULID returned from insert.
There are no server-assigned identifiers, which is what makes offline creation
work without reconciliation later.
const id = await db.insert("tasks", { title: "Prep sambar", done: false, station: "tiffin" }); // each key becomes one operation on one cell await db.update("tasks", id, { done: true }); // null clears a cell; the column disappears from the row await db.update("tasks", id, { station: null }); // delete writes a tombstone, it does not erase history await db.delete("tasks", id);
Reading
Reads hit local storage and never wait on the network, but they are asynchronous
because storage engines are. list returns visible rows sorted by row
id, which is insertion order since ULIDs are time-prefixed.
const row = await db.get("tasks", id); // undefined if absent or deleted const rows = await db.list("tasks"); // visible rows, oldest first
Reacting to change
subscribe fires after every committed batch, local or remote. The
event carries which rows changed and where the change came from, so you can
re-read only what you need.
const unsubscribe = db.subscribe((event) => { // event.origin is "local" or "remote" // event.changes is [{ table, row }, ...] // event.ops is the operations just committed if (event.changes.some((c) => c.table === "tasks")) refreshTasks(); });
Values are any JSON: strings, numbers, booleans, null, arrays, objects. They are stored whole, so a nested object is one cell and concurrent edits to different keys inside it do not merge separately. Promote fields you edit independently to their own columns.
Transports
A transport moves opaque messages between peers. It may lose, duplicate, or reorder them. Pass as many as you like; the engine deduplicates operations, so running several at once costs nothing but bandwidth.
Broadcast, for one device
Syncs tabs and workers of the same origin through BroadcastChannel.
No infrastructure, no configuration, useful in almost every browser app.
import { openSpace, broadcast } from "tangentfeed"; const db = await openSpace({ space: "kitchen-42", transports: [broadcast()] });
WebRTC, across networks
Devices connect directly over data channels. A signaling server introduces them and relays connection descriptions, then plays no further part. Kill it after two peers connect and they keep syncing.
import { openSpace, broadcast, webrtc } from "tangentfeed"; const db = await openSpace({ space: "kitchen-42", transports: [ broadcast(), webrtc({ signaling: "wss://sync.example.com", iceServers: [ { urls: "stun:stun.l.google.com:19302" }, { urls: "turn:turn.example.com", username: "u", credential: "p" }, ], onSignalingState: (state) => showStatus(state), }), ], });
STUN alone fails for roughly one connection in six, because some networks refuse direct peer traffic. Without a TURN server those users see sync silently never working. Self-host Coturn or use a hosted provider.
QR pairing, with no server
Two devices exchange connection blobs by camera or copy and paste. The offering device mints the space id, the answering device adopts it. Everything after the handshake is identical to any other transport.
import { openSpace, manualPair, existing, generateDeviceId } from "tangentfeed"; const deviceId = generateDeviceId(); const pair = manualPair({ deviceId, onState: (s) => showStatus(s) }); // device A: show this as a QR code const invite = await pair.createOffer(); // device B: scan the invite, show the answer back const answer = await pair.acceptOffer(invite); // device A: scan the answer, and the channel opens await pair.acceptAnswer(answer); const db = await openSpace({ space: pair.space, deviceId, transports: [existing(pair)], });
Browsers block getUserMedia on plain http, so scanning will not work
on a bare LAN address. Serve over HTTPS, or let people paste the codes, which
works everywhere.
Storage adapters
Storage is chosen per replica and never affects the protocol. Replicas on different engines sync with each other and converge to identical state, which the test suite verifies directly.
| Option | Where | Notes |
|---|---|---|
"indexeddb" | Browsers | Default when available |
"memory" | Anywhere | Default outside browsers. Lost on reload |
SqliteAdapter | Node, Electron, Bun | Real database file you can query |
| Your own | Anywhere | Implement the adapter interface |
SQLite
Construct the adapter yourself so the SQLite binding stays your choice, and so browser bundles never pull in a native dependency.
import Database from "better-sqlite3"; import { SqliteAdapter, betterSqliteDriver } from "@tangentfeed/adapter-sqlite"; const db = await openSpace({ space: "kitchen-42", storage: SqliteAdapter.open(betterSqliteDriver(new Database("tasks.db"))), });
The result is an ordinary SQLite file, readable while the app runs:
sqlite3 tasks.db "SELECT table_name, column_name, value FROM ops ORDER BY id;"
| Table | Contents |
|---|---|
ops | The operation log. The primary key is the clock stamp, so key order is causal order |
cells | Materialized state: the winning operation per cell |
meta | Frontier, persisted clock, recorded peer frontiers |
Drivers ship for better-sqlite3 and node:sqlite. Bun and
Expo bindings fit the same four-method shape.
Writing an adapter
An adapter stores operations, stores the winning operation per cell, persists the frontier and clock, and applies batches atomically. That last requirement is not optional: a crash midway through a batch must leave the log and the materialized state agreeing.
Encryption
Cell values are encrypted with XChaCha20-Poly1305 before they enter the log, so storage, transports, and relays only ever hold ciphertext. Every peer in the space needs the same secret.
// derived from a passphrase, salted with the space id encryption: { passphrase: "correct horse battery staple" } // or supply 32 random bytes shared out of band encryption: { secret: keyBytes }
What is protected
Values, and only values. The ciphertext is bound to the operation id, so a ciphertext lifted from one operation and pasted into another fails authentication rather than silently relocating data.
What is not
Table, row, and column names travel in the clear, along with timestamps and device identifiers. A relay learns the shape and timing of your activity, never its content. Row tombstones are also plaintext by design, so that peers without the key can still order deletes correctly.
There is no recovery path. If every device loses the passphrase, the data is gone. Key rotation is not supported in v0.1 either, because ciphertexts are bound to operation ids, so re-keying means rewriting data rather than turning a dial.
Compaction
The log grows with every write, so superseded operations need reclaiming. Compaction is safe by construction: it never drops an operation that a known peer has not yet received.
const stats = await db.compact(); // { removed: 10000, rowsReclaimed: 0, blockedBy: [] } // see what would happen without touching storage await db.compact({ dryRun: true });
Reclamation is bounded by a horizon: the lowest point every known peer has
acknowledged. One long-absent peer therefore blocks reclamation, which is why
blockedBy names the peers responsible instead of leaving you guessing
why nothing happened.
Deleted rows
Tombstones are kept by default. Reclaiming them early would let a peer that has been away reintroduce a deleted row, so it requires an explicit opt-in and happens only when the horizon has passed every operation belonging to the row.
await db.compact({ includeTombstones: true });
A reasonable schedule is on startup and then hourly while the app is open. It is cheap and safe to call often.
React
The hooks subscribe to the engine and re-read only the slice that changed. Reads are local, so first paint arrives quickly, but they are still asynchronous and the hooks report a loading state for that first pass.
import { useSpace, useRows, useTable } from "@tangentfeed/react"; import { broadcast } from "tangentfeed"; function Tasks() { const db = useSpace({ space: "kitchen-42", transports: [broadcast()] }); const { rows, loading } = useRows(db, "tasks"); const { insert, update, remove } = useTable(db, "tasks"); if (loading) return <p>Loading</p>; return ( <ul> {rows.map((task) => ( <li key={task.id}> <input type="checkbox" checked={task.done === true} onChange={(e) => update(task.id, { done: e.target.checked })} /> {String(task.title)} </li> ))} </ul> ); }
| Hook | Returns |
|---|---|
useSpace(options) | The space, or null until it opens |
useRows(db, table) | { rows, loading } |
useRow(db, table, id) | { row, loading } |
usePeers(db) | Reachable peer ids |
useTable(db, table) | { insert, update, remove } |
API
openSpace(options)
| Option | Type | Meaning |
|---|---|---|
space | string | Required. Peers only sync within the same space |
deviceId | string | Replica identity. Defaults to a fresh random id |
storage | string or adapter | "indexeddb", "memory", or an adapter instance |
transports | array | Zero or more. Omit for a purely local database |
encryption | object | { passphrase } or { secret } |
onError | function | Clock drift, malformed operations, transport failures |
Two replicas claiming one identity will evict each other from signaling. Take
particular care with sessionStorage, which browsers copy when a tab
is duplicated. Persist an id in localStorage or IndexedDB, or mint a
fresh one per session and let catch-up repopulate the replica.
The space object
| Member | Description |
|---|---|
insert(table, values) | Creates a row, returns its id |
update(table, row, values) | One operation per key |
delete(table, row) | Writes a tombstone |
get(table, row) | The row, or undefined |
list(table) | Visible rows, oldest first |
subscribe(cb) | Fires after every commit. Returns an unsubscribe function |
peers() | Reachable peer ids across all transports |
frontier() | How far this replica has seen from each device |
compact(options) | Reclaims superseded operations |
close() | Stops replication and releases storage |
engine | The underlying engine, for protocol-level work |
Packages
| Package | Purpose |
|---|---|
tangentfeed | Batteries-included entry point |
@tangentfeed/core | Engine, clocks, merge, replication. Zero dependencies |
@tangentfeed/adapter-idb | IndexedDB storage |
@tangentfeed/adapter-sqlite | SQLite storage |
@tangentfeed/crypto | End-to-end encryption |
@tangentfeed/transport-broadcast | Tabs and workers |
@tangentfeed/transport-webrtc | WebRTC mesh and QR pairing |
@tangentfeed/signaling-server | Blind signaling relay |
@tangentfeed/react | React hooks |
Protocol
The protocol is specified independently of this implementation, so a client in another language can interoperate. What follows is an orientation; the specification itself is the authority.
Operation
| Field | Meaning |
|---|---|
id | The clock stamp, globally unique, so replays are no-ops |
table, row, column | The cell. Column "-" is reserved for row tombstones |
value | Any JSON value, or a e1: envelope when encrypted |
hlc | Hybrid logical clock stamp |
device | The writing replica |
Clock encoding
A stamp is millis-counter-device in fixed-width lowercase hex, 34
characters total. Plain string comparison equals logical ordering, which means
storage engines can sort stamps without understanding them.
Drift protection
An operation stamped more than five minutes ahead of the receiving clock is rejected outright. Accepting it would let one broken clock win every conflict far into the future.
Sync session
Peers exchange a hello with their clocks, then frontiers, then the operations the other side is missing, then acknowledgements. After catch-up they forward new operations as they happen. The exchange repeats on every reconnect, which is how gaps heal.
Conformance
The suite is language-neutral JSON: a batch of operations plus the exact state and frontier that must result. An implementation is conforming when it produces identical results from every vector applied in each of these orders.
- As given
- Reversed
- At least two independent shuffles
- Shuffled with every operation duplicated
- One operation at a time, shuffled
Passing all five is the practical expression of the core guarantee: delivery order, duplication, and batching cannot change the outcome. The reference implementation runs this matrix against both its storage engines.
Deployment
Signaling server
Stateless, small, and unable to read your data. It tracks who is present in a space and relays connection blobs, nothing else. Restart it freely.
npx @tangentfeed/signaling-server # listens on :8787
Serve it behind TLS as wss:// in production. If you run several
instances, route peers in the same space to the same instance, since presence is
per instance.
An always-on replica
Direct peer sync needs both devices open at once. A small Node process holding a replica removes that constraint: each device syncs with it whenever convenient, and it is a backup as a side effect. It has no special authority, it just has good uptime.
import Database from "better-sqlite3"; import { SqliteAdapter, betterSqliteDriver } from "@tangentfeed/adapter-sqlite"; import { openSpace, webrtc } from "tangentfeed"; const db = await openSpace({ space: process.env.SPACE, storage: SqliteAdapter.open(betterSqliteDriver(new Database("replica.db"))), transports: [webrtc({ signaling: process.env.SIGNALING })], encryption: { secret: keyBytes }, // optional: it holds only ciphertext }); setInterval(() => db.compact(), 3600_000);
Hosting the app
The client is static files. Anything that serves HTML works. Serve over HTTPS so camera pairing and clipboard actions are available.
Recipes
Give a device a stable identity
Persist the id somewhere a duplicated tab will not inherit, which rules out
sessionStorage.
import { generateDeviceId } from "tangentfeed"; function deviceId() { let id = localStorage.getItem("device-id"); if (!id) { id = generateDeviceId(); localStorage.setItem("device-id", id); } return id; }
Share a space by link
A space id plus a secret is everything needed to join. Put the secret in the URL fragment, which browsers do not send to servers.
// https://app.example.com/#space=kitchen-42&key=BASE64KEY const params = new URLSearchParams(location.hash.slice(1)); const db = await openSpace({ space: params.get("space"), encryption: { secret: decodeKey(params.get("key")) }, transports: [broadcast(), webrtc({ signaling: SIGNALING })], });
Show sync status honestly
People tolerate being offline. They do not tolerate not knowing. Surface peer count and signaling state rather than a spinner.
Model rows for merging
Fields that get edited independently belong in separate columns, because merging happens per cell. A title and a status merge cleanly. The same two values nested inside one object do not, since the object is a single cell.
Troubleshooting
Peers stay at zero
Check signaling state first. If it never reaches connected, the server or URL is wrong. If it connects but no peers appear, the two clients are probably in different spaces. If peers appear but channels never open, the network is blocking direct traffic, which is common on guest Wi-Fi. Test on a phone hotspot to confirm, and add TURN to fix it properly.
Two tabs never see each other
Broadcast requires a real origin, so pages opened from the file system are isolated. Serve over http or https. If both tabs share a device id, one evicts the other; give each a distinct id.
Clock drift errors
A device's clock is more than five minutes off. Fix the system time. The protection is deliberate: accepting the operation would poison conflict resolution far into the future.
Compaction reclaims nothing
Read blockedBy. A peer that has been away pins the horizon, and
nothing below it can be dropped until that peer catches up or you stop tracking
it.
Storage keeps growing
Call compact() periodically. Without it the log keeps every
historical write forever.
Status and limits
Version 0.1. The protocol is stable enough to build on and the conformance vectors pin down its behaviour, but the wire format is not frozen until 1.0.
Known limits
- A space syncs whole. There is no partial replication or per-row access control.
- One user's trusted devices is the model. Multi-user collaboration with permissions is not.
- Values merge whole, so there are no collaborative text or ordered-list types yet.
- Key rotation requires rewriting data rather than a configuration change.
- Peers must overlap in time to sync directly. An always-on replica is the answer today.
Planned
- A typed schema layer with inference.
- Store-and-forward mailboxes, so peers that never overlap still converge.
- React Native adapters.
- A second implementation in another language, validated by the same vectors.