ADR 0023: The page keeps the board, the server says what it costs¶
- Status: accepted
- Date: 2026-11-11
- Plan: the v3 plan (perf/fewer-requests, after 828cc402): seven milestones, M1 to M7, named below.
Context¶
perf/fewer-requests took a 5,000-issue board's page from Lighthouse 61
to 94 by asking less. What was left was the shape of the app, not a list
of requests:
- every list built every issue again on the server, and a board's lanes did it once a lane (9 lists, 18,225 rows read);
- every change event reloaded the board's lists, counts and labels;
- Quasar shipped 281 KB of CSS for 14 dialogs and one menu;
- nothing said where time went: the bottlenecks were found by reading code, and a sync's thousand sequential forge reads were never seen;
- a check of a forge never synced listed every issue as a creation, one page row a write (5,199), as it simulated a consent it wasn't given.
The goals beyond: a page that answers a person's act in under 100 ms on a large board, and, later, a native app or a remote server on the same protocol.
Decision¶
1. Traces, without the SDK (M1)¶
Requests, git processes, loads, view queries, forge requests and their
waits, syncs and a page's slow interactions are OpenTelemetry spans,
sent as OTLP/JSON by the obs package when OTEL_EXPORTER_OTLP_ENDPOINT
is set (to Grafana Tempo: make obs-up). The wire format and the
environment are OpenTelemetry's; the SDK isn't used (ADR 0014: few
dependencies), so no sampling and no metrics. Every reply says its cost
in Server-Timing. A page sends traceparent on each request, and an
interaction over 200 ms to POST /api/slow, so a trace starts where the
person acted.
2. Sync says what it would do (M2)¶
A check does what a sync would, consent included, and says it as counts
by forge, way and operation, warnings grouped by reason, a few examples
each. With no [forges] table, a forge origin fetches from syncs; one
only a push URL names is listed off: the file alone turns it on (ADR
0008).
3. Vue on plain Vite (M3)¶
The web app keeps Vue and drops Quasar: dialogs are the browser's own
<dialog> (GbDialog.vue), the filters' help a popover. Considered and
left: a framework port or web components (a rewrite of 14,500 lines of
components for a smaller runtime no one would feel), pages rendered by
the Go server (two renderers once a native app comes), server or static
rendering in Node (a second server beside the binary, and nothing static
to render on a live board). The build writes brotli beside each file.
4. The page keeps the board (M4, M6)¶
The server keeps the issues it built while the board's version holds
(issues.KeptList), and serves the whole board as a snapshot with its
stamp (GET /api/snapshot), then the changes since a cursor (GET
/api/changes), from a ring of its last issue events. Lists answer
ETags and 304s. The page holds the snapshot, asks which ids each of its
lists holds (GET /api/issues/ids: many filters, sorted, in one request
of a few KB), shows its own copies of them, pages them without a request,
and writes optimistically, rolled back on a refusal. The grammar is the
server's. The page answers the terms a board's lanes and ticks are made
of (is:, field:value, label:, no:, commas and minus, the sorts by
creation and update) from its copy (web/src/local/query.ts, the
server's rules), and asks the server the same in the background: its
answer wins where they differ, and the e2e tests fail on a difference.
Everything else (free text, involves:, dates, @me, a sprint) is the
server's alone. On slow 4G that is the difference between 470 ms and
72 ms for a filter typed. JSON stays the format: the browser's
own parser is faster than any decoder shipped to it.
5. Interactions are named and held (M7)¶
A person's acts are a registry (web/e2e/interactions.ts): each a
budget, whether it is optimistic, how many requests it may make. Every
pull request checks that an optimistic act shows before its answer and
asks no more than its share; the nightly times each one on the 5,000-issue
board against testdata/perf/budgets.txt. A feature that adds an act
adds its entry.
Consequences¶
make obs-upand one environment variable answer "where did the time go" from Tempo's API, for a person or an agent.- The page holds the whole board in memory: fine to about 50,000 issues; beyond, the snapshot pages.
- The board's common terms are answered twice, in the page and on the
server, the server's answer the truth; a rule changed in
domain/issues/filter.gomust change inweb/src/local/query.tstoo. - Every act is held, in the page, to 100 ms on the worst setup (a CPU 6x
slower, slow 4G) when the page answers it, 200 ms when it needs the
server (
testdata/perf/budgets.txt). - A native app or a remote server speaks the same snapshot, changes and events; this ADR adds no login and changes no guard (ADR 0010).