Skip to main content

Subscriptions

Subscriptions control which paths a client materializes and receives during sync. They are a visibility/bandwidth mechanism, not an authority mechanism.

Why subscriptions exist

Large rooms often contain more state than one client view needs. Subscriptions help by:
  • Reducing client-side materialization scope
  • Reducing relayed traffic for non-matching paths
  • Keeping UI selectors focused on relevant data

Initial subscription

Set subscription patterns at document creation:
const doc = await createDoc({
  serverUrl,
  room,
  subscribe: ["world/**", "notes/welcome"]
});
Default is broad (["**"]) when no subscription is provided.

Runtime subscription updates

Update patterns dynamically:
doc.subscribe(["world/**"]);
doc.onSubscriptionChange(({ patterns }) => {
  rerender(patterns);
});
Use runtime updates for route/view changes in single-page apps.

Path checks

Use helper checks before expensive reads:
doc.isSubscribed("world/player1"); // true/false
This is useful for conditional data access and UI guardrails.

Glob semantics (practical)

Typical pattern behavior:
  • ** matches everything
  • foo/** matches foo and descendants
  • * matches one path segment
Keep patterns simple and intentional; over-broad patterns defeat subscription benefits.

What subscriptions affect

Subscriptions affect client materialization and relay filtering behavior. They do not replace write authority enforcement. Authority remains governed by policy/capability rules.

Design pattern: route-scoped subscriptions

In UI apps:
  1. Map each route/view to a small set of patterns
  2. Apply doc.subscribe(...) on route activation
  3. Expand only when user workflows demand wider context
This keeps bandwidth and memory pressure predictable.

Debugging subscription issues

If expected updates are missing:
  1. Confirm path actually matches active patterns
  2. Check runtime subscription updates are applied
  3. Verify view code is not reading outside subscribed scope
  4. Confirm issue is visibility-scope related, not authority rejection

Common mistakes

  • Treating subscription as access control
  • Leaving stale narrow patterns active after navigation
  • Using complex overlapping patterns without tests
  • Assuming doc.onChange always implies subscribed materialized changes

Runtime SDK event subscriptions

The runtime SDK (createNodalMergeSdk) uses a separate event subscription model via sdk.on(event, handler). These are process-level lifecycle and protocol events, not path-pattern subscriptions.
const stop = sdk.on("connected", () => {
  console.log("room connected");
});

// Unsubscribe by calling the returned function
stop();

NodalMergeSdkEvent reference

EventPayloadWhen it fires
"connected"noneWebSocket session opened and welcome received
"disconnected"noneWebSocket connection closed (clean or error)
"reconnect"{ attempt: number }Reconnect attempt starting (with backoff)
"state"{ state: "connecting" | "connected" | "disconnected" }Any connection state transition
"message"raw WebSocket message stringEvery inbound WebSocket frame (low-level)
"error"{ msg: string }Server-sent error envelope received
"presence"presence envelope objectPresence update received from server
"signal"WebRTC signal envelopewebrtc-offer, webrtc-answer, or webrtc-ice received
"runtime-message"NodalMergeRuntimeMessageAny parsed server message that passes parseRuntimeMessage
"transport"{ active: "ws-only" | "ws+webrtc" }Active transport layer changes

Common patterns

Connection lifecycle:
sdk.on("state", ({ state }) => {
  setConnectionState(state); // "connecting" | "connected" | "disconnected"
});
Error and rejection handling:
sdk.on("error", ({ msg }) => {
  console.error("server error:", msg);
  // msg may be "reject.control_plane_forbidden: ...", "auth: ...", etc.
});
Observing all runtime messages:
sdk.on("runtime-message", (msg) => {
  // msg.type is a NodalMergeRuntimeMessageType, e.g.:
  // "projection.build.completed", "replay.read-range.result", "pack", etc.
  if (msg.type === "projection.build.completed") {
    handleProjectionReady(msg);
  }
});
Presence updates:
sdk.on("presence", (envelope) => {
  updateCursorLayer(envelope);
});
WebRTC signaling:
sdk.on("signal", (signal) => {
  if (signal.type === "webrtc-offer") {
    handleOffer(signal.from, signal.sdp);
  }
});
NodalMergeRuntimeMessage.type values are exhaustively typed in NodalMergeRuntimeMessageType. Use a switch or type-narrowed handler rather than string comparison against arbitrary values.