Skip to content

perf(renderer): faster big-chat open + bounded memory (#2162)#2957

Open
KarloAldrete wants to merge 11 commits into
PostHog:mainfrom
KarloAldrete:perf/renderer-perf-bundle
Open

perf(renderer): faster big-chat open + bounded memory (#2162)#2957
KarloAldrete wants to merge 11 commits into
PostHog:mainfrom
KarloAldrete:perf/renderer-perf-bundle

Conversation

@KarloAldrete

Copy link
Copy Markdown
Contributor

Problem

Issue #2162 — renderer CPU & memory on real, heavy sessions. Three concrete symptoms:

  1. Opening a big chat freezes the UI. A 48k-event transcript is parsed, committed and rendered entirely and synchronously on open — ~1.1 s of main-thread jank before the first paint.
  2. A chat's memory is never freed. session.events is append-only and stays resident after you navigate away. With a few big chats open (Command Center / "ADHD mode") the renderer heap climbs toward the "memory-eviction" crash reason the app already ships in apps/code/src/main/index.ts.
  3. Sidebar & Command Center re-render on every streamed token. Both subscribe to the whole sessions store, so a token from any agent re-renders them — and the grid rebuilds all N cells, i.e. O(N²·tokens).

Changes

Six focused changes. The open/memory ones (1–4) are backed by a production A/B benchmark; the subscription ones (5–6) by render-count tests.

  1. Tail-first parse — parse the tail of the ndjson first so the latest turns paint immediately, instead of JSON.parse-ing the whole ~110 MB log up front.
  2. Pre-freeze eventsObject.freeze each event at creation so immer skips its deep-freeze walk on commit (~240 ms → ~5 ms on a 48k-event session).
  3. Windowed open + on-demand scrollback — open a tail window (render O(visible)); load older history by re-reading the log from disk (OS page cache) on scroll-up instead of pinning it in memory.
  4. Evict inactive transcripts — free session.events ~20 s after a chat loses focus; rehydrate from disk on return.
  5. Sidebar stable-signature subscription — rebuild the task→session map only when a sidebar-relevant field changes, not per token. (This is perf(sidebar): stop re-rendering on every streamed token #2710, folded in — see below.)
  6. Command Center stable-signature subscription — same fix for the grid (the multi-agent case).

Production A/B benchmark

Two production (minified) builds — baseline = main, this PR — same machine and user profile, opening the same real 48k-event chat. Renderer heap = live objects after forced GC; CPU summed across all Electron processes.

Opening the chat (cold open):

baseline this PR
Worst main-thread freeze 1076 ms 198 ms (−82%)
Total main-thread busy 2358 ms 702 ms (−70%)
CPU, all processes 8.7 s 1.6 s (−82%)

N=6 reload distributions don't overlap (baseline median 1042 ms, range 1000–1069; this PR well below).

Renderer heap (live objects):

baseline this PR
One big chat focused 198 MB 54 MB (−73%)
After navigating away 196 MB 53 MB (−73%)
4 big chats ("ADHD mode"), settled 713 MB 61 MB (−91%)

Cross-checked on a second, current task (96 MB log): 188 → 63 MB. No regression in idle CPU, DOM node count, or GPU/VRAM.

📊 Full report — every axis (RAM · CPU · GPU · DOM · jank) with charts — attached below.

Relationship to #2710

Supersedes #2710 — change #5 is that PR's commit and #6 is the same pattern applied to the Command Center grid; they belong together. Happy to close #2710 in favor of this once reviewed.

How did you test this?

  • Unit tests added with the changes: ensureEventsLoaded.test.ts, sessionLogs.chunked.test.ts, sessionStore.test.ts, commandCenterSignature.test.ts, computeSidebarSessionSignature.test.ts, useSessionEventsResidency.test.tsx, useSidebarSessionMap.test.tsx.
  • pnpm --filter @posthog/core test1778 pass; pnpm --filter @posthog/ui test (sessions / sidebar / command-center) → 341 pass; pnpm typecheck clean; biome check clean on changed files.
  • Production A/B benchmark across two minified builds (numbers above), measured via Chrome DevTools Protocol + /proc + /sys, forced GC before each heap read.
  • Manual: opened a 48k-event and a 96 MB chat; scroll-up loads older history; switching tasks evicts then rehydrates the transcript; streaming output unchanged.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Created with PostHog Code

The sidebar consumed the whole `sessions` record via `useSessions()`, which
immer replaces on every appended event (one per streamed token). Since the
sidebar is mounted at the root, that re-rendered the whole tree on every token.

`deriveTaskData` only reads four session fields (isPromptPending,
pendingPermissions size, cloudStatus, cloudOutput.pr_url) -- never `events`:

- Add `computeSidebarSessionSignature` (core, pure): a primitive signature of
  just those fields.
- Add `useSidebarSessionMap` (ui): subscribes to that signature and rebuilds the
  taskId -> session map only when a sidebar-relevant field changes.
- `useSidebarData` uses it instead of `useSessions()`.

Render-count test: 20 streamed tokens caused 20 sidebar re-renders before, 0
after (and 1 when a relevant field actually changes).

Part of PostHog#2162
session.events is an append-only mirror of the on-disk ndjson log that
was never freed, so renderer memory grew unbounded across open tasks.
Evict the events of unfocused, idle sessions after a grace window and
rehydrate from disk on refocus (ensureEventsLoaded keeps the session
warm). Never evicts a streaming session, a queued-turn session, or a
live cloud run. Part of PostHog#2162.
useCommandCenterData subscribed to the whole sessions Record via
useSessions(), so every appendEvents (one per token) rebuilt the cells
and re-rendered the grid. The grid only needs deriveStatus's 4 fields;
cell transcripts update independently via each EmbeddedSessionView's own
subscription. Mirror the sidebar fix (PostHog#2710): subscribe to a stable
status signature and rebuild the session map only when it changes.
Part of PostHog#2162.
Reloading an evicted transcript re-read the whole ndjson (178MB) and
JSON.parsed ~100k lines synchronously — a ~500ms+ main-thread freeze.
Now parse the last 256KB first so the latest messages render in ~1-2ms,
then parse the full history in yielding chunks (parseSessionLogContentChunked)
without blocking, and swap it in for scrollback. Measured 1.5ms vs 1710ms
(1134x) for the tail on a real 323MB / 54k-event session. Part of PostHog#2162.
The first open of a finished task went through reconnectToLocalSession,
which parsed the whole ndjson up front — the same ~500ms+ freeze as the
refocus path. Fetch raw content instead of pre-parsed logs, seed the
transcript from the tail (instant), derive sessionId/adapter from the
head (the sdk_session marker sits at line ~4), and parse the full
history in background chunks. Reuses commitLoadedEvents' streaming guard.
Part of PostHog#2162.
Committing a freshly-parsed transcript to the immer-backed session store
made immer deep-freeze every event object — measured ~240ms for a 48k
event session (immer's per-element isDraftable/handleValue machinery is
~50x slower than a plain Object.freeze loop). Events are immutable log
data, so freeze them as they're built; immer then short-circuits on
Object.isFrozen. ~240ms to ~5ms on load. Part of PostHog#2162.
…n scroll

Opening a finished task parsed + committed + rendered the ENTIRE history
(100k events) up front — seconds of main-thread hitches, even though you
only see ~15 messages. Now a transcript opens as a tail window (latest
~1000 events, instant) with the rest kept as raw text outside the immer
store; scrolling toward the top pulls in older chunks, anchored so the
viewport doesn't jump. Opening f0117a1c: ~1763ms of blocking -> ~209ms.
This is the Claude-fast model: open cost is O(visible), not O(history).
Part of PostHog#2162.
The tail window kept every ndjson line of an open transcript resident as raw
text for scrollback, pinning ~110MB per open chat. A production heap benchmark
put one 48k-event session at ~270MB while focused vs ~54MB with this change
(and Command Center "ADHD mode" multiplied it across chats). Drop the in-memory
copy and re-read the log from disk (OS page cache) on scroll-up, slicing only
the older chunk. Scroll-up is rare and user-initiated, so a little latency
there buys a large, always-on memory win. Older lines are start-indexed and
append-stable, so a grown log still slices correctly.
@greptile-apps

greptile-apps Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Comments Outside Diff (2)

  1. packages/core/src/sessions/sessionLogs.ts, line 453-490 (link)

    P2 parseSessionLogContentChunked is exported and tested but is not imported anywhere in the codebase — sessionService.ts and every other caller use the synchronous parseSessionLogContent. The function and its dedicated test file are dead code under simplicity rule feat: task creation and task list improvements #4 (no superfluous parts). If this is being prepared for a future caller, a comment noting its intended use would prevent it from being removed; otherwise it should be deleted along with sessionLogs.chunked.test.ts.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

  2. packages/core/src/command-center/commandCenterSignature.test.ts, line 51-70 (link)

    P2 Several structurally identical tests across commandCenterSignature.test.ts and computeSidebarSessionSignature.test.ts repeat the same shape (baseline → mutate one field → expect signature differs) and would read more clearly as a single it.each table. The style guide prefers parameterised tests. The computeSidebarSessionSignature test does use it.each for part of its suite; the command-center tests could follow the same pattern for the four "changes when X changes" cases.

    Context Used: Do not attempt to comment on incorrect alphabetica... (source)

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (1): Last reviewed commit: "perf(sessions): re-read log on scroll-up..." | Re-trigger Greptile

Comment on lines +229 to +234
const delta = el.scrollHeight - scrollHeightBeforeLoadRef.current;
if (delta > 0) {
el.scrollTop += delta;
isAtBottomRef.current = false;
loadingOlderRef.current = false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 The scrollback gate (loadingOlderRef) is only released when delta > 0, leaving it permanently true in two situations: (1) prependEvents is a no-op for an empty slice (guard at the top of prependEvents), so items.length never changes and this effect never fires — triggered when an older log chunk parses entirely to zero entries but hasOlder is still true; (2) even when items are prepended, if the virtualizer hasn't yet reflected the height change before this layout effect runs, delta is 0 and the gate stays locked. Either way, every subsequent scroll near the top checks !loadingOlderRef.current and short-circuits, permanently blocking scrollback for the lifetime of this component instance.

Suggested change
const delta = el.scrollHeight - scrollHeightBeforeLoadRef.current;
if (delta > 0) {
el.scrollTop += delta;
isAtBottomRef.current = false;
loadingOlderRef.current = false;
}
const delta = el.scrollHeight - scrollHeightBeforeLoadRef.current;
loadingOlderRef.current = false;
if (delta > 0) {
el.scrollTop += delta;
isAtBottomRef.current = false;
}

Review feedback (PostHog#2957): the scrollback re-trigger gate was cleared only inside
`if (delta > 0)`, so a load whose height the virtualizer hadn't measured yet —
or one that prepended nothing renderable — left the gate stuck and permanently
blocked further scroll-up. Release it whenever the anchor effect fires after a
load, and make `takeOlderEntries` skip older chunks that parse to zero entries
so it never reports `hasOlder` alongside an empty slice.
Review feedback (PostHog#2957): `parseSessionLogContentChunked` (and its only helper
`parseLogLine`) had no callers — the windowed open parses the tail synchronously
and loads older chunks on demand, so the background chunked parser was dead.
Drop it and its test.
Review feedback (PostHog#2957): collapse the repeated "changes when X changes" cases
into a single it.each table, matching the parameterised style already used in
computeSidebarSessionSignature.test.ts.
@KarloAldrete

Copy link
Copy Markdown
Contributor Author

Thanks for the review — addressed all three findings:

  • [P1] Scrollback gate could lock up (VirtualizedList.tsx): the re-trigger gate was cleared only inside if (delta > 0), so a load whose height the virtualizer hadn't measured yet — or one that prepended nothing renderable — left it stuck and blocked further scroll-up. The gate now releases whenever the anchor effect fires after a load, and takeOlderEntries skips older chunks that parse to zero entries so it never reports hasOlder alongside an empty slice. (007c860)
  • Unused chunked parser: removed parseSessionLogContentChunked and its only helper parseLogLine plus the test — dead since the windowed open parses the tail synchronously and loads older chunks on demand. (6edfb0a)
  • Test style: collapsed the repeated "changes when X changes" cases into an it.each table, matching computeSidebarSessionSignature.test.ts. (f027eea)

Core (229) + UI sessions/command-center tests green, typecheck + biome clean.

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