Repository navigation
A coarser observation channel for a reading of a whole list: tracking a 2,354-entry MediaListCollection registers about 95,000 field reads #53
Description
Summary
We are building Yakitate, a native macOS client for AniList, on Baton 0.16.0. Its model derives what the panel shows from the user's whole list, so it reads every entry's facts out of the generated lenses inside Observations { ... }. At 2,354 entries that reading registers about 95,000 field reads, one per slot, and tracking them costs several times the read itself: 48 to 56 ms in the closure and 35 to 40 ms more before Observations delivers the value, against 12 to 13 ms for the same pass untracked. Requested: a second, coarser channel that a reader of a whole operation can register on once, beside the per-slot channel views use, or a statement that composing it in the app, as we now do, is the intended answer.
Current behavior
- A read registers per field.
docs/principles/store-is-the-ui-state.md, "The idea": "a lens accessor registers the read of one field of one record through a static key path"; "Spelled today": "Recordis the observable object, with one invalidation channel per slot". The Invalidation channel entry (docs/terminology.md, "Store"): "what a read of a slot registers on and only a change of that slot notifies", in Swift "an Observation key path, one per slot index and shared by every record". So a reading makes oneaccesson a record's registrar per field it reads. - The cost is stated as Observation's: "a tracked field read costs what an
@Observableproperty costs; an untracked one less" (same principle, "Consequences"). The README's comparison table gives the Swift read back as "28 ns a field, 0.54 µs a field in a view body". - No channel coarser than a slot exists for data, by design. The same principle leaves Relay's "snapshots, seen-record sets, re-reads on overlap" to Observation, and lists "Query-level watchers that re-execute an operation and emit a tree" under "Not this". A handle's phase reads its root's verdict, settled "after a batch that changed a null, a link, an error or a deletion" (Phase and Operation handle,
docs/terminology.md, "Generated"), so a reader of the phase is not told of a changed scalar below the root. - The list is
MediaListCollection, fetched as six chunks, each chunk one operation value with its own handle. Each entry's reading touches about 40 fields across itsMediaList,Media,MediaTitle,MediaCoverImageandAiringSchedulerecords. At launch the six roots come from the image (Hydration and Persistence, image,docs/terminology.md, "Store").
What we measured
Release build, Apple M1 Pro, macOS 26.5.2, Baton 0.16.0; a public AniList list of 2,354 entries in six MediaListCollection chunks; three runs each.
| Step | Time |
|---|---|
One reading over the six handles inside Observations { ... }: the closure |
48 to 56 ms |
| The same reading: from the closure's return to the value delivered, the observers installed on about 95,000 slot reads | 35 to 40 ms |
| The same pass outside any tracking scope | 12 to 13 ms |
| The lens pass alone (an earlier measurement, 2,075 entries) | 4.1 ms |
| Launch from the image, session start to the list in the UI, the reading run twice | 258 to 291 ms |
| of which the image's hydration of the six roots | 65 to 100 ms |
| Launch from the image with the composition below, session start to the list in hand | 95 to 129 ms, about two thirds of it hydration |
One chunk of 400 entries re-read under its own Observations when one of its records changes |
about 15 ms |
- The reading ran twice at launch because one observation covered every handle: once for the list, and again when a further operation, the week's airing schedule, was attached and the observation restarted.
- Derived, not measured: the tracked closure is 48 to 56 ms over about 95,000 registrations, 0.5 to 0.6 µs each, in line with the README's 0.54 µs a field in a view body; counting the 35 to 40 ms of installation and taking away the 12 to 13 ms the untracked pass costs, tracking adds about 0.7 to 0.9 µs per field read. Either way, about 0.5 to 0.9 µs per
access, which at this size is most of the reading.
What we do today
One Observations stream per operation handle, so a change in one chunk re-reads that chunk; a reading is applied only when it differs from the last; and the first reading at launch is taken untracked and synchronously, before the streams start. The tracked readings still run once per chunk after the list is in hand. This is a composition in the app and needs nothing new from Baton. What it keeps is the cost of a change: a +1 on one entry costs a chunk's re-read, about 15 ms, rather than a field's.
Requested behavior
Per-slot registration is what buys the principle's first sentence, "A view body that read a field is invalidated when that field of that record changes and at no other time", and the README's "Only changed views re-render". We are not asking to replace it. The request is for a second, coarser channel for code that reads a large operation whole and would rather register once. Options, in Relay's words where it has them; the shape and the word are yours:
- An operation-level channel. A reader registers once, on the handle or its root, and is told when a commit changes a record its reading reached; with it, a way to read the operation's data without registering each field. This is the shape of Relay's
subscribeon a snapshot, whoseseenRecordsdecide whether a commit's updated records concern it: by record, not by field. We know the principle names seen-record sets among what Baton leaves to Observation; this would be opt-in for whole-operation readers, not what views use. - Registration per record rather than per slot, for such a reading: one channel per record, told when any of its slots changes, so an entry registers at most five times rather than about forty, while a view body keeps the per-slot channel.
- Nothing in the runtime: the composition above as the intended answer, said where the docs speak of reading outside views, with a bench row for a tracked pass of this size so an adopter can budget it before building.
Which would the library prefer, or is there a shape we have not seen? We measured the Swift runtime only. The Kotlin runtime's channel is snapshot state per slot (Invalidation channel: "a slot nobody read has none"), and we have no numbers for it.
Why this is needed
An app that opens a list of thousands of entries from the image at every launch and derives its panel from the whole list pays the per-field registration on every launch and on every change to the list. At 2,354 entries the two tracked readings were most of the launch, more than the hydration. The per-chunk composition brings launch under 130 ms, but each adopter with a list this size has to find it, and its granularity is a chunk. #51 asks for a bench of the same collection's ingest and launch; this is its read side. We can share the per-run timings.
Activity
Answer a derivation over a whole operation
Write the Unreleased changelog for #51 and #53
shergin commentedon Oct 19, 2026
Decided on 2026-10-18: no new observation channel. The composition you describe is the intended answer, now A derivation over a whole operation in the derived-state recipe, with bench rows for a tracked pass of this size on both runtimes. Option 3, then.
Why no coarser channel
subscribeon a snapshot and itsseenRecords: the seen-record set the vision deletes, with a second way to read the data beside the lens.BENCHMARKS.md's 579 ns a tracked field. The new rows confirm it on a list of this shape: 647 ns a field in one tracking scope over the whole list, against 585 ns a field in a row body of eight in the same runs, and linear from one chunk to two. The Kotlin runtime's channel is the same, snapshot state per slot read, and on the JVM the same reading pays 57.3 ns a field, since Compose's read observer adds the state to a set. The difference is the platform's.The composition, as the recipe gives it
Observationsper operation handle (Kotlin:snapshotFlow), so a change in one chunk re-reads that chunk.The model is compiled on both runtimes as
ListModelbeside the AniList tests (Swift, Kotlin), andListModelTestsbeside each proves the whole list in hand in the turn the model is made with no request, the streams' first readings changing nothing, and a save of one entry changing that entry alone.The numbers. M1 Pro, macOS 26.5.2, Xcode 26.6, release build; the JVM is OpenJDK 21 on the same machine. The list is #51's recording under
spec/anilist/, 1,000 entries, 44 slots an entry for the row's fragment and seven for the model's five values. Best of three Swift runs and two JVM runs; the entry is the AniList list, with the medians.ObservationsorsnapshotFlowto its first valueA launch from the image reaches both chunks' handles ready in 21.6 ms in Swift. Your 15 ms re-read of a 400-entry chunk is the same order as the 15.6 ms here for 500 entries read whole; reading the five values a panel needs brings it to 2.7 ms.
Reopen with a whole-operation derivation that the per-handle composition cannot keep within its budget at a measured size; the reopening line of the decision now says so, and its Not chosen names both options.
Merged in #55 as
4bbdc78; not yet released. Closing as not planned.