Sync and offline
A pulse synchronizes through connectors. Each connector opens one session per document and exchanges binary frames. The engine takes care of what to send, in which order, and what to do when it comes back.
Three promises
connectPulse and loadPulse expose the same three milestones. loadPulse waits for ready by default; pass waitFor: "synced" or a list of connectors to wait for more.
import { connectPulse, createPulse } from "pulsify-core";
const note = createPulse(Note, data, { connectors: [local, remote] });
const { ready, connected, synced } = connectPulse(note);
await ready; // usable data (local connectors first)
await connected; // every connector finished its initial download
await synced; // every connector acknowledged our local history
Missing documents, aborts and timeouts reject with a PulseError carrying a code such as NOT_FOUND. Pass signal to cancel loading.
Working offline
There is no offline mode to enable. connectPulse means "stay synchronized with every connector, local or remote, until disconnectPulse". Writes always commit locally and the exact history is retained; connectors that fail are retried on their own.
import { connectPulse, createPulse, getPulseMeta } from "pulsify-core";
const note = createPulse(Note, data, {
connectors: [local, remote],
onError: (error) => console.warn(error), // once per outage, not per retry
});
connectPulse(note); // from now on, stay synchronized until disconnectPulse
// The network drops: the remote connection retries with backoff, edits continue.
note.tags.push("offline");
getPulseMeta(note).status; // "offline"
// It comes back: a fresh session, a full sync, then "synced" again.
- Lost connection. A closed transport, a failed opening or a request without an answer within
timeoutMsmarks the connection asreconnecting. It waitsinitialDelayMs × factor^n, capped atmaxDelayMsand shortened at random byjitter, then opens a new session. - Lazy waking. When the pulse has something to upload (a local edit) while a connector is down, that connector retries within a second instead of waiting out a long backoff, and at most once a second while it stays down. Documents nobody edits keep their backoff.
- Full resynchronization. A new session assumes nothing about the connector: it sends the local version vector, downloads what is missing, then uploads everything the connector does not acknowledge. A server restored from an old backup, or one that lost its data, gets the complete history back.
- Errors that a retry cannot fix stop that connector: authorization (
FORBIDDEN), an incompatible document or migration, an invalid frame, or an exception in your own code while applying received data. The pulse status becomeserrorandonErrorreceives the cause. Other connectors continue. - Local first. Local connectors are opened before remote ones and
readyresolves from local data, so an app starts offline. A local connector that fails does not block the remote ones. - Deadlines.
timeoutMsbounds the promises ofconnectPulseandloadPulse, not the intent: after aTIMEOUT, connectors keep retrying. CallingconnectPulseagain returns a new set of promises and retries at once.
Statuses
idle not connected: never connected, or disconnectPulse
syncing connecting, nothing loaded yet
ready data loaded, some connector not yet synchronized
synced every connector acknowledged the complete local history
offline a connector is unreachable and being retried; edits continue
error a connector stopped on an error a retry cannot fix
Listen instead of polling: the listener runs with the current status, then once per change.
import { subscribePulseStatus } from "pulsify-core";
const stop = subscribePulseStatus(note, (status) => {
banner.hidden = status !== "offline"; // called now, then on every change
});
Tuning and observing
createPulse(Note, data, {
connectors: [local, remote],
reconnect: { initialDelayMs: 250, maxDelayMs: 30_000, factor: 2, jitter: 0.25 }, // defaults
timeoutMs: 10_000, // open, request and initial-sync deadline
});
// reconnect: false gives up after the first failure, like a one-shot sync.
In a browser, the online event retries waiting connections at once and probes established ones, which catches sockets that died during sleep. While navigator.onLine is false, attempts wait for that event. Between those events, detecting a transport that died without closing is the connector's job, once per socket: the cloud connector pings each idle socket, and local connectors cannot die silently.
import { getPulseMeta, setOnlineMode } from "pulsify-core";
const [connection] = getPulseMeta(note).connections;
connection.on("reconnecting", (error, delayMs, attempt) => {
showBanner(`Offline, retry #${attempt} in ${delayMs} ms`);
});
connection.on("synced", hideBanner);
connection.status; // "connecting" | "synced" | "reconnecting" | "error" | …
connection.lastError; // the last failure
setOnlineMode(false); // pause every attempt (airplane mode in your app)
setOnlineMode(true); // retry now and probe live sessions
Disconnect on purpose
import { connectPulse, disconnectPulse } from "pulsify-core";
disconnectPulse(note); // stop: no session, no retry, edits continue
note.title = "Offline on purpose";
await connectPulse(note).synced; // missing rows exchanged both ways
// After a FORBIDDEN or an incompatible document, fix the cause, then:
await connectPulse(note).synced; // restarts stopped connectors
Automatic batching
Writes commit immediately and locally. Uploads are coalesced per connection and per microtask, so a burst of edits costs one frame. Large batches split at protocol limits.
note.title = "A";
note.tags.push("b");
note.author.name = "C";
// One frame per connection, scheduled with queueMicrotask.
// On Cloudflare Workers: await connection.syncBack() or ctx.waitUntil(it).
Scopes for tests
A scope holds one instance per document ID. Use separate scopes to simulate several clients in one process, which makes collaboration tests trivial.
import {
createMemoryConnector, createPulseScope, loadPulse, PulseRelay,
} from "pulsify-core";
const relay = new PulseRelay(); // in-memory server
const connector = createMemoryConnector(relay);
const alice = createPulse(Note, { id: "shared", ...data }, {
scope: createPulseScope(), connectors: [connector],
});
await connectPulse(alice).synced;
const bob = await loadPulse(Note, "shared", {
scope: createPulseScope(), connectors: [connector], waitFor: "all",
});
alice.title = "From Alice";
bob.title; // "From Alice" after a microtask
Custom transports
A connector is a tiny interface. Anything that moves complete binary frames works: WebSocket, WebTransport, BroadcastChannel, a native bridge.
import type { Connector } from "pulsify-core";
const connector: Connector = {
kind: "remote",
async open({ docId, signal, onMessage, onClose }) {
const socket = await openMyTransport(docId, signal);
socket.onBinary(onMessage);
socket.onClose(onClose);
return {
async send(bytes) { socket.sendBinary(bytes); },
close() { socket.close(); },
};
},
};
Protocol versions
The WebSocket connector speaks envelope 1 by default. Against a server that negotiates pulsify-v3, pass { protocolVersion: 3 } to enable grouped scalar runs and condensed state downloads. The server always stores exact history.