---
title: Watch
description: Follow a board's writes as they land, with the design behind catch-up, retention, and resync.
---

`watch` follows a board's changes as an async iterable: it replays from a watermark you hold, then stays live. The surface is identical embedded and over HTTP. There is no second log behind it. Under `writePath: "wal"` the feed reads the same WAL that makes writes durable (see [write paths](/concepts/write-paths)); under `writePath: "lake"` it reads DuckLake's own change tracking.

## Pick a surface

| Surface      | What it is                                                  | Use it when                                                             |
| ------------ | ----------------------------------------------------------- | ----------------------------------------------------------------------- |
| `watch()`    | An async iterable: replays from a cursor, then follows live | The default for any long-running consumer                               |
| `changes()`  | A single page fetch, cursor in and cursor out               | A page-at-a-time cursor fits better, like driving a UI's own pagination |
| `onChange()` | An in-process listener for this node's own writes only      | Almost never directly; it powers `watch`'s instant local wakeup         |

## Usage

**Engine**

```ts watch.ts
for await (const event of engine.watch("acme_roadmap", { after })) {
  if (event.resync) {
    // re-read board state with a query, then continue.
    // The iterator already moved its cursor to event.from.
    continue;
  }
  console.log(event.records.map((r) => r.kind));
}
```

**HTTP client**

```ts watch.ts
import { connect } from "lakefront/client";

const board = connect({ url }).board("acme_roadmap");

for await (const event of board.watch({ after })) {
if (event.resync) {
continue; // same recovery as above
}
console.log(event.records.map((r) => r.kind));
}

````

`after` defaults to the current head, so a bare `watch()` follows the future. Pass an older watermark to replay first. The loop ends when `signal` (an `AbortSignal`) aborts. A read failure throws.

Both iterators yield the same two shapes:

| Event                       | Meaning                                                                     |
| --------------------------- | --------------------------------------------------------------------------- |
| `{ resync: false, records }` | A batch of change records                                                   |
| `{ resync: true, from }`     | The cursor fell behind retention; re-read state and continue from `from`   |

Resync is a value the loop yields and moves past, not an error to catch. The section below explains when it happens.

## Latency

A write on the node you are watching wakes the iterator immediately. A write landing on a different node arrives on the next poll, every 500ms by default (`pollMs` on `engine.watch`). The feed is complete either way; the wakeup only sharpens latency for local writes.

## Change records

Each record carries a `seq` (the watermark it landed at, comparable with the watermarks reads and writes report), a `kind`, a `payload`, and an optional `actor`:

```ts
interface ChangeRecord {
  seq: number;
  kind: "insert" | "upsert" | "update" | "delete" | "drop";
  payload: unknown; // shape depends on kind
  actor?: string;
}
````

`insert` and `upsert` payloads carry the full rows written. `update` carries the id and the patch, not a computed postimage: a consumer sees exactly what the caller sent. `delete` carries the deleted ids.

`drop` is a tombstone. It means the board was dropped or truncated, and it voids everything before it. A consumer that sees one should treat prior state as gone. This is also how a replica on another node learns a board disappeared (the [consistency page](/concepts/consistency) covers how watermarks tie reads to this feed).

## Retention and resync

The WAL is not an archive. Once the flusher drains a board's records into the lake, it trims them from the WAL table: the lake already holds that history, and a second durable copy would serve no reader. Under `writePath: "lake"`, snapshot expiry bounds the feed the same way.

A consumer whose cursor falls behind that bound cannot be handed a contiguous feed. Instead of an error, the feed yields `{ resync: true, from }` and continues from the new floor.

The trade asks two things of a consumer:

- Applying a change record must be idempotent, because the recovery is "read current state, then continue".
- There must be a rebuild path that re-reads state rather than assuming the tail is always available.

The engine's own sync machinery uses the same recovery internally: a replica that falls behind retention rebuilds from the lake rather than silently missing trimmed records.

## Cursor paging

`watch()` is `engine.changes(boardId, { after, limit? })` in a loop, plus the wakeup and poll logic. Call it directly when a one-shot page fits better:

```ts
const page = (await engine.changes("tasks", { after: 0, limit: 500 })).unwrap("changes");
if (!page.resync) {
  for (const record of page.records) {
    // apply record
  }
  // next call: engine.changes("tasks", { after: page.next })
}
```

Over HTTP this is the `changes.read` procedure. `BoardClient` does not wrap it; reach it with the lower-level RPC client:

```ts
import { createRpcClient } from "lakefront/contract";

const rpc = createRpcClient({ url, boardId: "acme_roadmap", token });
const page = await rpc["changes.read"]({ after: 0, limit: 500 });
```

## onChange

`engine.onChange(listener)` fires once per touched board, on a microtask, after a write's ack, and only for writes this process performed. A write landing on another node does not fire it. Its job is powering `watch`'s instant local wakeup; consumers that need every write should watch instead.

```ts
const unsubscribe = engine.onChange((event) => {
  // event.boardId, event.watermark, event.ops
});
```

## Over the wire

A server node exposes the feed as an SSE stream at `GET /watch`, which `board.watch()` consumes. The stream carries `change` and `resync` events plus a `: hb` heartbeat, is authorized with the `read` verb, and takes the same `after` query parameter. Exact frames and headers are in the [wire protocol reference](/reference/wire-protocol).

One more contract worth knowing: a replayed idempotent write (a mutation whose `idempotencyKey` the WAL has already seen) fires no change event on any surface. Its event fired when the original attempt landed, and firing again would double every trigger a retried duplicate touches. The [idempotency guide](/guides/idempotency) covers the exactly-once contract.
