Skip to content

perf(timeline): a thread cost a round-trip per reply, and six copies of "who is reading" - #824

Merged
github-actions[bot] merged 2 commits into
mainfrom
fix/reply-tree-n-plus-one
Aug 28, 2026
Merged

perf(timeline): a thread cost a round-trip per reply, and six copies of "who is reading"#824
github-actions[bot] merged 2 commits into
mainfrom
fix/reply-tree-n-plus-one

Conversation

@catomean

@catomean catomean commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Two related performance defects on the timeline, both structural, both mine.

1. Opening a thread cost a round-trip per reply

getReplies recursed: one query AND one enrichEventsForDisplay per node. Enrichment is itself several requests — profiles, projects, the reader's id, and the three reaction tables — so a thread cost roughly six round-trips per reply.

This is my regression from #811: I added the reader's-id lookup and the reaction-state queries to enrichment without noticing enrichment sat inside a per-node recursion.

Measured in production by opening one three-reply post: eight /auth/v1/user calls for a single page.

Fix: getReplies walks the tree a level at a time — one query per depth regardless of width — enriches the whole flat list once, then assembles parent→children from a map. A reply whose parent vanished mid-read simply does not attach, instead of orphaning its branch.

2. getCurrentUserId was defined six times, each uncached

Across timeline queries, timeline processors, groups, loans, projects, and the auth layer. Every copy calls supabase.auth.getUser(), which is a network call — it validates the token against /auth/v1/user. So one page asked the server who the reader was over and over, and the cache in fix 1 covered only four of the six importers.

On the timeline that lookup also sat on the critical path. Cold load, measured:

2429 → 3061  get_user_timeline_feed   632ms
3105 → 3351  auth/v1/user             246ms   ← depends on nothing
3367 → 3595  reactions ×3             228ms   ← waits on the one above

The identity lookup ran after the feed returned, and the reaction queries then waited on it — though it depends on neither.

Fix:

  • One definition, in the auth layer, caching the in-flight promise so concurrent callers collapse onto one request. Safe as module state only because that module talks exclusively to the browser client — a per-request server client would hand one request's user to the next, so the one caller that passes its own client (groups) keeps its uncached path.
  • Cache dropped on any auth state change, registered lazily on first use rather than at import: this module is imported very widely and an import-time subscription is a side effect every importer pays for, including every test that stubs the client.
  • getUserFeed warms the id alongside the feed request instead of after it, and asks for the total count concurrently rather than after enrichment.
  • check:one-current-user fails the build if a seventh copy appears. Wired into verify.

A test caught a real behaviour change

getUser() catches its own errors and reports them in error rather than throwing — so "could not ask" arrived looking almost exactly like "nobody is signed in", and would have been cached as signed-out for the rest of the page. Fixed the code, not the test.

Why the tests assert cost, not correctness

The recursive version built a perfectly correct tree. That is exactly why nothing caught it. So the tests pin the number of queries and enrichment passes.

The first version of the reply-tree mock returned rows by a level counter, ignoring what was asked for — which made "one query per level" and "one query per node" indistinguishable. Caught by mutation: swapping .in(parents) for .eq(parents[0]) stayed green. The mock now filters on parent_event_id.

Mutation-proved — each red, then green after restore:

mutation result
remove the user-id cache 3 of 4 red
one query per node instead of per level 1 red
enrich per level instead of once 4 red
a seventh getCurrentUserId definition gate red
the owner loses its definition gate red

The gate was also confirmed reachable through npm run, not only when run by hand.

Full unit suite: 261 suites, 2524 passed.

Not fixed, deliberately

/api/rates and projects each appear twice in the waterfall. I checked both: the second /api/rates is the browser's own stale-while-revalidate revalidation (cache-control: public, max-age=30, stale-while-revalidate=300), not our code. Not a defect, so not touched.

catomean and others added 2 commits August 28, 2026 22:50
getReplies recursed: one query AND one enrichEventsForDisplay per node.
Enrichment is several requests — profiles, projects, the reader's id, the
three reaction tables — so a thread cost roughly six round-trips per reply.
I introduced the reader's-id and reaction queries in #811 without noticing
they sat inside a per-node recursion.

Measured in production by opening one three-reply post: eight /auth/v1/user
calls alone.

Two changes, both structural:

- getReplies fetches the tree a LEVEL at a time (one query per depth,
  regardless of width) and enriches the whole flat list ONCE, then assembles
  parent -> children from a map.
- getCurrentUserId caches the in-flight PROMISE, so concurrent callers
  collapse onto one /auth/v1/user instead of racing to make several. A
  failure is never cached - caching "nobody is signed in" would outlive the
  blip and render the timeline as signed-out.

The tests assert the COST, not just the tree. The recursive version built a
perfectly correct tree, which is exactly why nothing caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5
The first version of this mock returned rows by a level counter, ignoring
what was asked for. That made "one query per level" and "one query per node"
indistinguishable: a mutation swapping .in(parents) for .eq(parents[0])
stayed green, so the cost assertion was decorative.

The mock now filters on parent_event_id, and a sibling-branch case pins that
both branches are fetched in one query AND that neither loses its children.
Both mutations are red against it and green after restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5
@github-actions
github-actions Bot merged commit cddb7ab into main Aug 28, 2026
6 checks passed
@github-actions
github-actions Bot deleted the fix/reply-tree-n-plus-one branch August 28, 2026 21:23
@catomean catomean changed the title perf(timeline): opening a thread cost a round-trip per reply perf(timeline): a thread cost a round-trip per reply, and six copies of "who is reading" Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant