Reactivity
How a write finds exactly the subscriptions it affects.
Queries record what they read. Writes record what they touched. A subscription recomputes when the two overlap. That is the whole mechanism.
Read keys
| Key | Recorded by |
|---|---|
table:<table> | an unindexed table().list() scan |
document:<table>:<id> | a table().get(id) point read |
index:<table>:<index>:prefix:… | query().withIndex(name, prefix) |
index:<table>:<index>:range:… | the same plan with range(bounds) |
Write keys
Every write emits keys for both the pre-image and the post-image of the changed row:
| Key | Emitted by |
|---|---|
document:<table>:<id> | every insert, update, and delete |
point:<table>:<index>:… | one concrete key per declared index |
table:<table> | fallback when the schema has no entry for the table |
Emitting both images is what makes a row moving into or out of a prefix invalidate both sides. Recolour a note from amber to rose and the amber subscription sees a removal while the rose subscription sees an addition — from one update.
Matching
A subscription recomputes when a change key is contained by one of its
dependencies. Prefix and range dependencies ignore the plan's limit, because a
row outside the returned window can still change the result.
Precision is an optimization; a missed invalidation is a correctness bug. Any
key the engine cannot compute exactly degrades to table:<table> rather than
being dropped, and a malformed key is rejected rather than ignored.
Recomputation and fan-out
Recomputation is shared per query shape: ten subscribers watching the same path with the same arguments and identity cost one query, not ten. Each is then diffed against its own previous value, so fan-out width, not recomputation count, is the dominant cost.
Fan-out is isolated per subscriber. One whose query throws, or whose socket has died, is reported and skipped — it never stalls the committing mutation or starves the others.
Sequences and resume
Each subscription carries its own monotonic sequence inside its owning object. There is no global sequence across Durable Objects.
| Client cursor | Answer |
|---|---|
| Equal to the current sequence | Nothing; already current |
| Covered by retained history | Those patches, in order |
| Older than retained history | A snapshot at the current sequence |
| Unknown subscription | An error |
Retained history is bounded and does not survive hibernation, so resume is best effort and always degrades to snapshot replacement rather than failing.
What a patch does not carry
A patch says which rows were added, updated, and removed. It does not say where they belong — the client merges by id, so a changed row keeps its position and a new one goes at the end.
Declare the order with the query rather than at each place the rows are drawn:
useQuery(api.notes.list, {}, { sort: (a, b) => Number(b["updatedAt"]) - Number(a["updatedAt"]) });Carrying order on the wire was considered and rejected: the only faithful form is the full ordered id list, which for a two-hundred-row query is several kilobytes attached to a patch that otherwise describes one changed field.
Notifications are paced, values are not
The cache records a new value synchronously — a reader always sees the latest. What is paced is the notification: the first change to a query is announced immediately, and further changes within the same frame collapse into one. Twenty people typing costs twenty patches and one render.
The lever that matters
An indexed query wakes only its slice; an unindexed one wakes on every write to the table. Measured at 27× the throughput at 64 subscribers and 31× at 256. If one thing in an application is worth getting right, it is this. → Limits