How Pulsify Cloud works
Pulsify Cloud is the managed, paid backend for your pulses: a CRDT database you never operate. The client code is the one you already write with pulsify-core; only the connector changes.
- Automatic scaling. Each root document is its own isolated actor with its own storage. A million users means a million small actors, not one big database.
- Global distribution. On Cloudflare, a root lives near its first user and is reachable from every location.
- Pay as you go. Idle sockets hibernate and idle instances scale to zero. You pay for activity and storage, not for capacity.
- Built-in authentication. Users connect with a token from your backend; each root keeps its own access table, and changes apply to open sockets immediately.
Roots and documents
A document belongs to the root named before its first /: toto/tata belongs to toto. Sub-pulses created with { parent } get such IDs automatically, so a whole tree lives in one root.
alice root: one namespace, one actor
├── alice/notes/n1 sub-document
├── alice/notes/n2 sub-document
└── alice/work sub-document
└── alice/work/plan sub-document of a sub-document
bob another root, isolated from alice
acme a root does not have to be a user
A root is usually a user, but nothing enforces it. Use a team, an organization or a project if that is how your data is shared. Pick roots so that the documents edited together live together: a root is the unit of placement, storage and isolation.
One socket per user and root
The connector opens one WebSocket per root and multiplexes every document of that root over it. Pulsify frames already name their document, so the cloud routes them without decoding the operations. Ten open notes cost one socket.
import { createCloudConnector } from "pulsify-cloud";
import { loadPulse } from "pulsify-core";
const cloud = createCloudConnector({
url: "https://pulsify.example.com", // Cloudflare or Unikraft: same URL scheme
token: () => fetch("/api/pulsify-token").then((response) => response.text()),
});
const notebook = await loadPulse(Notebook, "alice", { connectors: [cloud], include: true });
cloud.connections; // [{ root: "alice", state: "open", documents: ["alice", "alice/notes/n1", …], … }]
Subscriptions
Loading a pulse subscribes to it; the server never pushes documents you did not load. Subscribe to exactly what a screen needs:
// The root document only
await loadPulse(Notebook, "alice", { connectors: [cloud] });
// One sub-document, without its parent
await loadPulse(Note, "alice/notes/n1", { connectors: [cloud] });
// Everything reachable from the root
await loadPulse(Notebook, "alice", { connectors: [cloud], include: true });
// A sub-document and all its children
await loadPulse(Folder, "alice/work", { connectors: [cloud], include: true });
// Selected fields only
await loadPulse(Notebook, "alice", { connectors: [cloud], include: { notes: true } });
// Unsubscribe: the socket stays open for the other documents of the root
destroyPulse(note); // or disconnectPulse(note)
Each subscription has its own version vector, so opening a document downloads only its missing rows. When the last document of a root is closed, the socket closes after lingerMs (5 seconds by default).
Lost connections
Connected pulses reconnect on their own with backoff, and every reconnection is a complete synchronization. All documents of a root come back on one new socket.
A socket can also die without closing: a laptop that slept, a network switch, a proxy dropping idle connections. The connector checks sockets when they are needed, once per socket rather than per document:
- Before using a silent socket. An edit sent on a socket that received nothing for 5 seconds goes out with a ping. Any frame counts as an answer, so a busy socket never pings.
- When the user comes back. Focusing the window or showing the page checks silent sockets; the network coming back checks all of them.
- Without an answer within 5 seconds, the socket is closed and its documents reconnect, the edit included.
- Passive readers. Nothing needs a socket that only receives, so while someone is there (input in the last 5 minutes, page not hidden) a silent socket is pinged every 2 seconds. Any frame counts as an answer, so busy sockets never ping. Set
intervalMs: falseto check on demand only. - A page left open (hidden, or untouched for 5 minutes) sends nothing at all, so the server can sleep. Its sockets stay open and still receive updates; if one died meanwhile, the first input, focus or edit finds out within 5 seconds and resynchronizes.
import { createCloudConnector, createPresence } from "pulsify-cloud";
const cloud = createCloudConnector({
url,
token,
heartbeat: {
staleMs: 5_000, // ping a socket silent this long before using it
timeoutMs: 5_000, // no frame this long after a ping: the socket is dead
intervalMs: 2_000, // background check while someone is there; false = on demand only
}, // defaults; heartbeat: false relies on transport errors
// Optional: by default every connector of the page shares `presence`.
presence: createPresence({
activeMs: 300_000, // "someone is there": input within this long, page not hidden
unfocusedMs: 30_000, // ...or within this long when the window lost focus
}),
});
On Cloudflare the runtime answers pings itself: a hibernated root stays asleep and no duration is billed. Each ping still counts as an incoming WebSocket message, billed as 1/20 of a request. On Unikraft Cloud, a ping wakes a scaled-to-zero instance for a moment: only pages in use ping in the background, so instances sleep when users walk away.
A screen nobody touches, such as a dashboard on an office TV or a kiosk, stops background pings after 5 minutes like any idle page. It still receives updates, but a socket that died silently would only be noticed at the next touch. Give such displays activeMs: Infinity.
Desktop and mobile apps
Presence works in every webview because it relies on what they all report: input events (pointer, keyboard, wheel, touch) and timers. When timers run late, the process was suspended (laptop sleep, mobile app in the background), and every socket is checked on resume. Visibility, focus, pageshow and resume only make detection faster where the webview reports them:
| Runtime | Page hidden when minimized or in the background |
|---|---|
| Browsers, Electron (Chromium) | Yes |
| Tauri, Electrobun on macOS (WKWebView) | On minimize, not when covered by another window |
| Tauri, Electrobun on Windows (WebView2) | Never (tauri#10592). Minimizing removes the focus, so pings stop 30 seconds after the last input; at once with native events. |
| Tauri on iOS and Android | The app is suspended in the background; sockets are checked on resume |
A window that loses focus (another app in front, or minimized) counts as away 30 seconds after the last input instead of 5 minutes. Every webview reports focus, including WebView2. To react the moment a window is minimized or hidden, forward the native state to the page presence, which every connector shares:
// Tauri, desktop and mobile: minimized or hidden means away, shown or focused means back.
import { presence } from "pulsify-cloud";
import { watchTauriPresence } from "pulsify-tauri";
const stop = await watchTauriPresence(presence);
Electron needs nothing: Chromium reports minimized and hidden windows to the page.
Debugging and status displays
A client talks to several servers at once: each root it uses has its own socket, served by that root's Durable Object or Unikraft machine. So events are reported where they happen:
- Presence belongs to the window, not to any server: whether someone is there, and why.
- Each connection reports its own server (the first frame of every socket names the backend, the Durable Object ID or machine instance, and the Cloudflare edge or Unikraft metro), its lifecycle, every ping with why it was sent, round trips, a dead socket and process suspensions. A reconnection is a new connection; a new server ID means a new instance.
- Each pulse reports its synchronization status.
Use them for a debug panel, logs, or to show the state of a window.
import { presence } from "pulsify-cloud";
import { subscribePulseStatus } from "pulsify-core";
// The window: called now, then on every change. It involves no server.
presence.on("change", (p) => {
// { present, reason: "blur" | "idle" | "focus" | "native" | …, visible, focused, away, lastActivity }
presenceBadge.textContent = p.present ? "here" : `away (${p.reason})`;
});
// The servers: one connection per root, to its Durable Object or Unikraft machine.
// Called with the current connections, then with each new one (reconnections included).
cloud.onConnection((connection) => {
const { root } = connection;
connection.on("server", ({ backend, id, location }) => log(root, "served by", backend, id, location));
connection.on("state", (state, error) => log(root, state, error?.message)); // open | closed
connection.on("ping", (reason) => log(root, "ping", reason)); // send | interval | network | return | resume
connection.on("pong", (latencyMs) => log(root, "pong", latencyMs));
connection.on("timeout", (waitedMs) => log(root, "dead socket", waitedMs));
connection.on("suspended", (gapMs) => log(root, "process was suspended for", gapMs));
connection.on("role", (docId, role) => log(docId, "role", role));
});
cloud.connections; // the current ones, without listening: root, state, server, documents, latencyMs, error
presence.state; // the same for the window
// Documents: idle | syncing | ready | synced | offline | error.
subscribePulseStatus(notebook, (status) => (statusBadge.textContent = status));
Storage and access
Each root stores three things:
- Patch rows of all its documents, in the same schema as the self-hosted stores. They are never decoded on the server: definitions, migrations and materializers stay in your client code.
- Grants: who can read, edit or own which prefix. See Authentication.
- Subscriptions of the sockets currently open, so a hibernated root wakes up knowing whom to notify.
Packages and backends
| Import | Contains | License |
|---|---|---|
pulsify-cloud | The client: connector, presence, management client, tokens, roles and the session protocol. | Apache-2.0 |
pulsify-cloud-server | The gateway and CloudRoot, the runtime-independent state machine of a root. | FSL-1.1-ALv2: any use except a competing service; Apache-2.0 two years after each release |
pulsify-cloud-server/cloudflare | A Worker and one Durable Object per root. | |
pulsify-cloud-server/unikraft-cloud | A Rust server and PostgreSQL in one Unikraft Cloud image, plus its schema. |
| Cloudflare | Unikraft Cloud | |
|---|---|---|
| Gateway | Worker | Rust server |
| One root | One Durable Object | One actor in the server process |
| Storage | SQLite, one database per root | PostgreSQL beside the server, one database per instance |
| Idle cost | WebSocket hibernation | Stateful scale-to-zero |
| Placement | Near the first user of each root | The metro of the instance |
Both backends pass the same conformance suite: same URLs, same errors, same live role changes. Switching is a URL change.
Wire protocol
GET /v1/health { "ok": true }
GET /v1/roots/{root}/connect WebSocket, one per user and root
GET /v1/roots/{root}/grants list grants
PUT /v1/roots/{root}/grants create or replace a grant
DELETE /v1/roots/{root}/grants?subject=…&prefix=…
GET /v1/roots/{root}/usage documents, rows, bytes, sockets (API key)
Subprotocols offered by the client:
pulsify-cloud.1.e2, pulsify-cloud.1.e1, pulsify-cloud.token.<jwt>
Binary frames: unchanged Pulsify frames, each names its document
Text frames: {"type":"welcome","server":{backend,id,location?}} server → client, first frame
{"type":"role","docId":…,"role":"editor"|…|null} server → client
{"type":"unsubscribe","docId":…} client → server
{"type":"ping"} → {"type":"pong"} socket heartbeat
The token travels as a WebSocket subprotocol because browsers cannot set headers on a socket; the server answers with the envelope only. Envelope 2 groups scalar runs; the server may still answer in envelope 1.