Skip to main content

Watch and subscribe

Polling a log for changes is either slow or expensive. A watch is the alternative: a long-lived connection on which the server tells you which aggregates changed, as they change. It is how a projection follows the log.

Notifications, not payloads

A watch delivers change notifications. Each one names the aggregate (org_id, aggregate_type_id, aggregate_id), the operation, and the versions involved:

OperationCodeFields
Write1FromAggregateVersion, ToAggregateVersion
Create5none
Delete0none
TrimStart3KeepFromAggregateVersion, the new floor

You then read the new batches yourself. The split is deliberate: the notification stream stays small and cheap to fan out, and event data only moves for the subscribers that want it, through the same ordered read path as everything else.

The first write to a new aggregate emits two notifications: a Create with no versions, and a Write carrying the range. Advance your cursor on the Write. Filter operation_types down to writes and you never see creates at all.

A notification fires when the batch becomes readable on the node you are watching, so a read on that node right after the notification finds the batch.

Scope

A watch filters by orgs, aggregate_types and aggregates (sets of ids), plus an optional operation_types set. Categories AND together; ids inside a set OR. A category you leave unset does not restrict.

The scope has to fit the cluster's routing rule, because a watch runs on one shard. Routing by org_id means the watch must name at least one org; by aggregate_type_id, at least one type; by aggregate_id, the specific aggregates. Leave that set empty and the server answers 9002 (IncompatibleFilters). Name keys that land on different shards and it answers 9001 (MultipleShardRoutes).

The Rust and .NET clients handle both. On 9001 or 9002 they read num_shards out of the error, open one connection per shard with an explicit shard_id, and merge the streams into one. You only meet these errors on the raw protocol. The cost is one socket per shard per watch; if one shard's connection fails, the whole watch fails.

Latency and coalescing

RequestedLatency is the minimum gap between notification frames. Changes that arrive inside the window merge per aggregate and operation: Write ranges widen to the lowest From and highest To, trims keep the highest floor. Nothing is dropped by merging, and ToAggregateVersion only moves forward, so a read from your cursor covers everything the merged notification stands for. Leave it unset and every change is flushed as it arrives.

The server caps it with --max-requested-latency-ms (default 2000). Ask for more and you get 8001 (LatencyTooHigh). An idle watch receives an empty heartbeat frame every 5 seconds.

Coalescing trades immediacy for fewer frames. If the user must see a write the instant it lands, read inline; do not wait on a watch.

Limits

  • Subscribers per shard. --max-watch-subscribers (default 16384). Each subscription reserves an event queue off the memory budget. Past the cap, a new watch gets 8005 (WatchTooManySubscribers).
  • Slow consumers are cut off. Each subscriber has a queue of 10,000 pending changes. A subscriber that stops draining it is removed and its connection closed; a frame write stalled past --client-connection-timeout-ms does the same. Merging does not save a consumer that stops reading.
  • Follower watches. With follower reads enabled, the Rust and .NET pools attach watches to a follower too. Notifications on a follower trail the leader the same way follower reads do.

Subscribe, then catch up

A watch covers the live tail, not the past. The server acknowledges a new subscription with an empty frame, and the client's watch call returns once that ack arrives: from then on, every change reaches you.

So the order for a projection is: start the watch, then read from your last processed version to catch up, then follow. The other order loses data. A write that lands after the catch-up read finished but before the watch registered reaches neither path.

The right order produces overlap instead, which is easy. Buffer notifications while the catch-up runs, then drop any whose ToAggregateVersion is at or below what you already processed for that aggregate. Nothing lost, nothing applied twice.

One gap the catch-up has to cover: an aggregate whose first write happened while you were down is in no cursor you hold, so "read from your last version" never touches it. Either watch a key set you already know, or list aggregates in the same scope the watch names and pick up the ones you have not seen. See Subscribing to live events and Building a read model.