WebSocket Protocol
KalamDB’s WebSocket protocol synchronizes a SQL result with a client: load existing rows, receive changes, and recover after a connection interruption. This reference describes the current message contract and the server and SDK mechanisms behind it.
For application code, start with Live Queries and use an SDK. This page is for transport implementers, durable-sync integrations, and developers investigating delivery behavior.
WebSocket protocol lifecycle
Connect to ws://localhost:2900/v1/ws for local development or wss://<host>/v1/ws in production. One authenticated connection can multiplex multiple subscriptions, identified by client-generated IDs. SQL writes use the HTTP API, PostgreSQL wire interface, or SDK; this socket carries subscription control and query results.
Delivery is bounded. The implementation combines a snapshot boundary, buffered changes, a replay log, and explicit delivery-gap errors. These are recovery mechanisms, not an unlimited or exactly-once event-history guarantee. Delivery guarantees and limits explains what “no missed events” requires.
Connection and authentication
Authenticate once per connection
A socket must authenticate with a JWT before subscribing. Obtain a token through the HTTP authentication flow or a configured identity provider. Username/password login is not a WebSocket message.
For post-connect authentication, send JSON text:
The protocol object is required in this message. Both json and domain serialization are supported. Wait for success before subscribing:
user is the authenticated user ID. Role values are lowercase: anonymous, user, service, dba, or system. Invalid credentials produce auth_error with a message, followed by a policy close. An unauthenticated subscription receives AUTH_REQUIRED.
Alternatively, authenticate during the HTTP upgrade:
- Native/server clients can send
Authorization: Bearer <JWT>. - Browsers can offer
kalamdb.jwt.<JWT>inSec-WebSocket-Protocol; the server echoes the accepted subprotocol. - Select the upgrade-authenticated format with
?serialization=jsonor?serialization=domain. JSON is the query-parameter default. MessagePack is rejected.
The authorization header takes precedence if both credential mechanisms are present. Do not put JWTs in URL query parameters. Treat authorization and subprotocol headers as credentials when configuring proxy logs.
Authentication fixes the connection’s identity and protocol. Sending authenticate again on an authenticated connection only echoes its current state; it does not rotate credentials or change users. Reconnect to use a refreshed token or a different identity, and discard state belonging to the previous user.
Origins and transport security
Use TLS (wss) outside local development. For browser access, configure explicit security.cors.allowed_origins and enable security.strict_ws_origin_check if a missing Origin must be rejected.
In the current implementation, an empty allowlist or * bypasses the origin restriction, including the missing-origin check. With a restricted allowlist, a supplied nonmatching origin is rejected; strict mode also rejects a missing origin. Origin checking supplements JWT authentication and row authorization—it does not replace them.
Subscribe and load existing rows
Supported SQL
A subscription accepts one direct-table query:
Use a namespace-qualified table and include its primary key in the projection. The live-query SQL surface is narrower than ordinary SQL: no joins, CTEs, aggregates, grouping, ordering, DISTINCT, LIMIT/OFFSET, table aliases, or computed/aliased projections. Use plain column names or * and a supported row predicate. Sort the materialized result in the client.
The server includes _version as synchronization metadata. _seq is not the public row-version field. A row version is separate from your application’s primary key; your table can still use familiar PostgreSQL-style IDs.
Subscribe request
Use a nonempty ID unique among active subscriptions on this connection. Keep the same query and authenticated scope when resuming it.
Field in options | Behavior |
|---|---|
batch_size | Initial-load page size. Defaults to 1,000 and is clamped to 1–1,000 rows. It is not a live-event debounce interval. |
last_rows | Load the newest N rows initially, ordered by version. N must fit in one batch. This does not maintain a rolling top-N result as future changes arrive. |
from | Resume strictly after this version, encoded as a decimal string. Takes precedence over last_rows. |
version_domain | Copy the domain returned in the acknowledgement. Required with from for a SHARED table. |
Include options: {} to request a full initial load with defaults. At the raw protocol level, omitting options requests live changes without existing rows. SDK convenience APIs may supply options for you. auto_fetch_batches is SDK-side behavior, not a server pagination command.
Acknowledgement and schema
An acknowledgement confirms registration and describes the result columns. For a first page with more rows to fetch:
Schema entries may also contain flags, such as pk, nn, and uq. Rows are named maps, not positional arrays. Interpret values with the schema; JSON 64-bit integer cells and version cursors are strings to preserve precision.
total_rows is currently sent as 0, not as a count of the complete result. Do not use it to conclude that a table is empty. Acknowledgement metadata can already describe the first page, but the rows arrive separately. Do not checkpoint an acknowledgement as if those rows had been applied.
For SHARED subscriptions, batch_control.version_domain identifies the ordered history. Preserve the complete object, including history_incarnation, table_id, scope_id, and partition_id, without reconstructing it. Later batches may omit it; retain the value from the acknowledgement.
Initial batches and pagination
The first batch is sent automatically when initial data was requested, including an empty batch for an empty result. For example:
The illustrative rows are shortened; real pages can contain up to batch_size rows. After applying a page with has_more: true, request the next one using its cursor:
Process pages sequentially. batch_num starts at zero; subsequent nonterminal pages use status: "loading_batch". A terminal batch has has_more: false and status: "ready". A single-page result can be ready immediately. Apply the final rows before declaring the initial view complete.
last_seq_id retains its historical field name but contains a row version. It is a pagination position, not an acknowledgement of durable client processing. snapshot_end_seq is internal server state and is not a field in the current batch_control wire contract.
How the snapshot and live stream meet
The server performs these steps for an initial load:
- Validate the SQL and permissions, bind the query’s filter and authorization, and register the subscription before fetching initial rows.
- Compute a local boundary from
MAX(_version)for the initial query. Cluster integration can wait for an apply barrier before this read. - Fetch pages within that boundary. Ordinary pagination selects versions after the previous page and at or below the boundary.
- Buffer concurrent notifications while the initial load is in progress. Versions covered by the boundary are excluded from that buffer; repeated buffered versions are deduplicated.
- Send the terminal batch, sort the buffered prefix by version, enqueue it, and switch the subscription to live delivery. A shared gate prevents a new live notification from overtaking that buffered prefix during the transition.
This closes the usual gap between “query existing rows” and “start listening.” It describes a version-fenced load and buffered handoff, not a protocol-level promise of an arbitrary historical or cross-table transaction snapshot. Concurrent-write behavior still depends on the storage and apply path in the deployed release.
Changes, patches, and materialized state
A change message contains subscription_id, change_type, and the relevant rows and/or old_values arrays. Arrays can hold multiple rows; do not assume one row per frame. Optional arrays can be absent.
Insert
An insert adds a row to the query result. It can represent a new database row or an existing row that now matches the filter and authorization.
Upsert by primary key so replayed inserts do not create duplicates.
Update and NULL handling
Updates are compact row changes, not RFC 6902 JSON Patch operations:
rowscontains the current non-NULL projected columns, plus primary-key and version metadata.old_valuescontains the previous values of changed projected columns, plus previous key/version metadata. It is not necessarily a complete previous row.- The current row includes unchanged non-NULL projected columns too. It is not just a list of changed fields.
For example, changing the title and setting note to SQL NULL can produce:
Replace the projected row by key, rather than blindly spreading it over the old row. A merge would incorrectly preserve note: "Draft". The current SDK materializers replace matching row maps; applications that require explicit nullable fields can normalize omitted projected NULL columns using the schema. Do not infer values for columns outside the subscription’s projection.
Delete and visibility changes
A delete removes the row identified by old_values. It may be a database deletion or a row leaving the authorized query result:
A visibility-loss notification contains identity and system metadata only. A normal SQL deletion can include other projected fields. Do not require a full before-image to remove a row, and do not assume the version in a visibility-loss payload is a new mutation cursor; see current implementation boundaries.
The server evaluates the row’s visibility before and after an update. Visibility means both the SQL filter and applicable row authorization succeed:
| Visible before | Visible after | Notification |
|---|---|---|
| No | No | None |
| No | Yes | insert |
| Yes | Yes | update |
| Yes | No | delete containing the old identity, without newly unauthorized values |
This is why a client must handle all three change types even when its application only issues UPDATE statements.
Client state and batching
Keep a keyed row collection per subscription. Apply all rows in an event, update the collection, and then notify the UI. The SDK’s materialized APIs, such as TypeScript live() and React useLiveQuery(), handle the collection for you; lower-level event APIs expose the changes for custom processing.
There are three different kinds of batching:
| Layer | What is batched |
|---|---|
| Initial load | Rows in initial_data_batch, controlled by batch_size and next_batch. |
| Server delivery | Pending notifications are drained in bursts of at most 128 before the socket loop returns to handling control traffic. They remain individual protocol messages. |
| Client/UI | An application can apply multiple events before publishing a rendered view. It must preserve each change and advance progress only after applying the whole prefix. |
The server shares encoded row payloads among subscribers with compatible projections, avoiding repeated row serialization. This is a fanout optimization; it does not bypass per-subscriber filtering or authorization. Frame batching, transport compression, and UI batching are separate mechanisms.
Remembering progress and reconnecting
What each layer remembers
| Layer | Remembered state | Lifetime |
|---|---|---|
| Server connection | Identity, subscription SQL, filter/projection, initial-load boundary, pending notifications | Active connection/subscription; not a durable consumer registration. |
| Server replay log | Recent change notifications for a table scope, with coverage metadata and version domain | Bounded process memory. |
| Shared SDK connection | Active requests, domain, pagination position, and consumed/acknowledged progress; bounded resume caches | SDK instance/process, unless a higher-level store persists it. |
| Application sync store | Materialized rows, query/identity/domain, completed-load state, and safe checkpoint | Whatever durable storage the application provides. |
The server does not store a durable per-client acknowledgement cursor for live queries. subscription_ack acknowledges registration. next_batch requests rows. SDK explicit acknowledgement updates client-side progress; there is no general live-query ack command in the JSON contract. Topic consumer ACKs belong to a different messaging API.
Versions and domains
_version, from, and last_seq_id must remain lossless. Preserve their decimal strings or use an appropriate integer type; do not convert them to JavaScript Number. Compare numerically within the same history, not lexicographically as strings.
A version is not a global event offset. Do not increment it, infer a missed event from a numeric gap, or compare versions across unrelated tables/scopes. Copy the returned version domain with a SHARED checkpoint. A changed history incarnation or scope invalidates the old checkpoint even if its numeric version looks plausible.
A checkpoint is valid only for the same authenticated scope, query, projection, and local state. Broadening a filter or changing the selected columns requires a fresh load. A new subscription ID with the same SQL normally requests a fresh view in the shared SDK; reusing an ID may recover its cached progress.
Resume request
After authenticating a replacement socket, recreate the subscription with the last safely applied version. For a USER table:
For a SHARED table, include the saved options.version_domain object as well. from is exclusive: covered replay returns changes after that version. Replayed notifications pass through the query’s filtering, projection, and authorization path.
A retained-log resume still uses acknowledgement/batch control to complete its loading phase, then delivers retained notifications as change messages. Do not interpret an empty initial batch during resume as an instruction to erase an existing view. Erasing/replacing belongs to a fresh snapshot, which must be tracked separately by the client.
Replay retention and failure recovery
The current server retains at most 16,384 notifications per table scope, across at most 64 scopes per process. A USER scope is a table plus a user; a SHARED scope is a table. These are implementation bounds, not a time-based retention SLA. Scope eviction, high write volume, restart, or reconnecting to a different node can make replay unavailable. Raft durability does not make this process-local replay cache durable.
Changes are recorded for tracked scopes before notification-worker enqueueing. If a delivery queue overflows, the affected subscription stops normal delivery and reports a gap. Already queued connection notifications are drained before the gap error is sent.
| Situation | Client action |
|---|---|
| Transport disconnect | Reconnect with backoff, authenticate, recreate subscriptions, and resume from safely applied progress when available. |
DELIVERY_GAP | Treat the subscription as stale. Unsubscribe it, then resubscribe from the last safe cursor. Replay can still succeed. |
CURSOR_EXPIRED | Discard the cursor/domain and request a fresh snapshot. Replace local state so rows deleted during the gap do not survive. |
| No completed local snapshot or no safe checkpoint | Start a fresh load. A partial initial-load pagination position is not a complete replicated view. |
| New user, query, projection, or incompatible history | Start a new local state generation and a fresh subscription. |
The shared SDK connection attempts cursor-based recovery for an already-active subscription receiving DELIVERY_GAP, and clears expired cursor state before retrying a fresh load on CURSOR_EXPIRED. A subscription that fails during startup can instead fail its creation call. Higher-level adapters must still handle error/reset state and replace their materialized collection for a fresh load.
Durable application checkpoints
For a durable replica or offline cache, commit row changes and the checkpoint in the same local transaction:
If the process crashes before commit, replay is safe because neither rows nor checkpoint advanced. If it crashes after commit, an idempotent keyed application can tolerate a duplicate. Persisting the cursor first can permanently skip work after a crash.
Automatic acknowledgement in the shared Rust subscription manager occurs when an event is yielded to the consumer. It is not evidence that an application callback, database transaction, or external side effect completed. Its explicit acknowledgement mode lets the consumer advance progress after durable work. Check the chosen language adapter’s API; not every SDK exposes the same checkpoint controls. For Flutter, see Offline Sync.
Keep initial-load pagination progress separate from completed-stream progress. Build a fresh snapshot in a separate generation, apply buffered changes in order, and publish it as authoritative only after successful completion. Never merge a replacement snapshot into a stale collection without removing keys absent from the new result.
Delivery guarantees and limits
The intended handoff and recovery path is: register before reading, buffer concurrent writes, finish the initial load, apply ordered changes, remember safe progress, and replay after interruptions. Within retained coverage, this can recover notifications that were not delivered over a broken connection. Duplicate delivery is possible and clients must tolerate it.
A fresh snapshot restores current state. It cannot recreate every intermediate event: a row inserted and deleted entirely during an unreplayable outage may never appear in that snapshot. Use durable topics and consumers when processing each event is the requirement, with the retention and acknowledgement policy appropriate to that workload.
Current implementation boundaries
The source does not justify a blanket “zero missed events under every failure” promise. In particular:
- Old-cursor fallback: a cursor older than an intact replay floor can use a storage scan for rows after that version. This recovers current rows, not a historical list of all updates/deletes. Use actual checkpoints from a completed view; arbitrary historical versions are not an event-history API.
- Visibility-loss versions: the current filter/RLS exit path builds a delete from the old row identity and its old
_version. The shared SDK also filters replayed rows using their versions. Do not assume every such removal advances the stream checkpoint; this interaction needs end-to-end validation before claiming lossless filtered replay. - Authorization invalidation: a stale policy/dependency guard fails closed, but the live protocol does not promise a reset event or a removal for every row previously cached after a policy change. Rebuild affected views when access rules or identity change.
- Snapshot scope: the initial-load fence is local row-version metadata, not a globally atomic snapshot across subscriptions or tables. Do not use the stream as a cross-table transaction-completion signal.
- Client durability: transport delivery and automatic SDK progress do not imply durable application processing or exactly-once external effects.
A deployment claiming no missed events should exercise writes during multi-page loading, slow consumers, overflow, reconnects, expired/domain-mismatched cursors, NULL updates, filter exits, access revocation, and process crashes around local commits. Successful connection setup alone does not establish these guarantees.
Authorization during live delivery
Authentication establishes the principal. Subscription registration checks table access and validates the query. USER-table routing is scoped to the authenticated user. Nonprivileged SHARED subscriptions bind row authorization; system namespace subscriptions require DBA/system privileges. See Row-Level Security for policy semantics and privileged-role behavior.
For each candidate notification, authorization and the SQL predicate determine visibility before projection. A row that becomes unauthorized is removed using its old identity without exposing its new protected values. Retained replay runs through the same delivery checks; a cursor is not permission to read other users’ data.
Bound authorization checks policy/dependency validity before and after row evaluation. A stale guard rejects the row. This protects future delivery, but cannot retract data already received or guarantee that a cached view is refreshed after every policy change. Treat application caches as belonging to a specific identity and access context.
The socket authenticates its JWT at connection setup; do not assume it revalidates token expiry or account revocation on every event. Reconnect with fresh credentials as part of the application’s session lifecycle. Message-size bounds, authentication attempt limits, subscription limits, and origin policy provide additional resource and connection protection.
Serialization, compression, and binary frames
Frame decoding
Serialization chooses JSON or the KDOM domain codec. Gzip compresses an encoded message payload independently of WebSocket extension negotiation. It is enabled for server output by default; ?compress=false reliably disables it. The current transport uses this query switch separately from the echoed protocol.compression value, so compression: "none" alone should not be treated as proof that binary gzip responses cannot arrive.
| Format | Uncompressed data message | Compressed data message |
|---|---|---|
| JSON | UTF-8 JSON text frame | Gzip-compressed UTF-8 JSON in a binary frame |
| Domain | KDOM binary frame | Gzip-compressed KDOM in a binary frame |
The server attempts gzip for payloads at least 512 bytes long and uses it only if the result is smaller. Small or incompressible messages remain uncompressed.
A receiver should:
- Parse text frames as JSON, including bootstrap and error messages.
- Convert binary frames to bytes (
binaryType = "arraybuffer"in browsers). - Check for gzip magic
0x1f 0x8band decompress when present, with bounded expansion. - Dispatch a resulting
KDOMenvelope to the matching codec; otherwise parse a JSON payload. Some server error/control paths send JSON even on a domain connection.
The successful authentication reply can already use the selected binary format, so initialize the decoder before authenticating. Bootstrap post-connect authentication can always be sent as JSON text. JSON-mode binary client requests receive UNSUPPORTED_DATA; domain-mode clients can send raw or gzip-wrapped KDOM. The server also accepts JSON text through its text-message handler.
Inbound gzip has a 16 MiB compressed-input cap and an expansion bound of the smaller of the configured message limit and 64 MiB. A smaller configured connection/frame limit applies first. These are safety ceilings, not a recommended message size.
KDOM envelope and message mapping
KDOM encodes typed domain values rather than UTF-8 JSON in an envelope. The 16-byte header uses little-endian multibyte integers:
| Byte offset | Field | Size | Current value |
|---|---|---|---|
| 0 | Magic | 4 bytes | ASCII KDOM |
| 4 | Epoch | u16 | 1 |
| 6 | Codec version | u16 | 1 |
| 8 | Message kind | u8 | Mapping below |
| 9 | Flags | u8 | 0; unsupported flags are rejected |
| 10 | Descriptor short ID | u16 | Current WebSocket contract descriptor 1 |
| 12 | Payload length | u32 | Number of payload bytes following the header |
The domain value system can distinguish omitted values, SQL NULL, JSON null, and void, and represent integers, decimals, UUIDs, timestamps, bytes, arrays, composites, and row sets. The live message adapter defines how those values map to the row/schema contract; do not invent an independent encoding from JSON examples.
| Kind | Contract mapping |
|---|---|
0x20 | Authentication request or success/error response |
0x21 | subscribe |
0x22 | unsubscribe |
0x23 | Application ping |
0x24–0x26 | initial_data_batch, with kind selected from its loading status |
0x27 | change |
0x28 | subscription_ack |
0x29 | next_batch cursor request |
0x2a | Defined in the domain kind registry; not a public reset command in the current client/server message enums |
0x2b | error |
The codec validates endpoint kinds and descriptor revision. A binary WsAck is a subscription acknowledgement, not a client durable-progress acknowledgement. Use the matching SDK codec and upgrade server/SDKs together when the epoch or message descriptor changes. Field-slot and domain-value tag definitions are maintained in the codec source.
Heartbeats, limits, and cleanup
WebSocket Ping/Pong control frames keep the connection alive. The server responds to a client Ping with Pong and tracks Pong/data activity. Native clients should service control frames; browsers handle protocol Ping/Pong automatically. {"type":"ping"} is also an accepted application message, but it has no separate JSON pong response.
| Setting or bound | Meaning |
|---|---|
websocket.auth_timeout_secs | Time allowed to authenticate. |
websocket.heartbeat_interval_secs | Server heartbeat interval. |
websocket.client_timeout_secs | Inactivity threshold used by heartbeat checks. |
security.max_ws_message_size | Incoming message/frame size limit. |
rate_limit.max_messages_per_sec | Configured message-rate control; enforcement depends on the request path. |
rate_limit.max_subscriptions_per_user | Configured per-user subscription limit. |
| 100 subscriptions | Hard cap per WebSocket connection. |
| 1,024 notifications | Catch-up buffer per loading subscription. |
| 8,192 notifications | Pending notification channel per connection. |
| 16,384 notifications / 64 scopes | Process-local replay bounds described above. |
Use the running server’s configuration for operational values; example configurations and defaults can differ. Buffer capacities are counts, not byte budgets or promised throughput. A slow consumer can exhaust them even with a small initial batch_size.
Unsubscribe when a view is no longer needed:
There is no separate unsubscribe-success message in this contract. Treat that subscription generation as closed and ignore any already queued frames for it. Connection cleanup unregisters remaining subscriptions. Authentication failures/timeouts use a policy close (1008); server shutdown uses going-away (1001). A close without a final application error must still trigger normal recovery logic.
Error contract
Dispatch on code, not exact human-readable message text. Connection-level errors can use identifiers such as protocol or rate_limit instead of an active subscription ID. Authentication failure uses the separate auth_error message.
| Code | Meaning / response |
|---|---|
AUTH_REQUIRED | Authenticate before subscribing. |
INVALID_SUBSCRIPTION_ID | Supply a nonblank subscription ID. |
UNAUTHORIZED | Check table access and row-policy configuration. |
NOT_FOUND | Check the referenced table or resource. |
INVALID_SQL, UNSUPPORTED | Fix SQL shape or unsupported options. |
SUBSCRIPTION_LIMIT_EXCEEDED, RATE_LIMIT_EXCEEDED | Reduce active work or request rate; use bounded backoff. |
DELIVERY_GAP | Stop treating this stream as current; attempt replay from safe progress. |
CURSOR_EXPIRED | Obtain a fresh snapshot without the old cursor/domain. |
MESSAGE_TOO_LARGE | Reduce payload size; check connection and decompression limits. |
UNSUPPORTED_DATA, PROTOCOL | Check negotiated encoding, codec revision, and message shape. |
SUBSCRIPTION_FAILED, BATCH_FETCH_FAILED, CONVERSION_ERROR | Treat the load as incomplete, inspect server logs, and retry or rebuild deliberately. |
Implementation sources
This reference was checked against the server and shared SDK source on 30 October 2026. These links pin the inspected revision so protocol changes can be compared explicitly:
- Client/server messages and batch options.
- Authentication, transport, error handling, and socket loop.
- Subscription registration and replay selection, initial-data reads, and snapshot-to-live flow control.
- Projection, update deltas, and visibility transitions, bounded replay log, and live authorization guards.
- Shared SDK recovery, progress and explicit acknowledgement, and replay filtering.
- KDOM header and value tags and WebSocket field-slot mapping.
Next: Reconnection and Resuming Subscriptions. For application-facing APIs, use the TypeScript subscriptions guide or Rust subscriptions guide.