Skip to content

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

Closed
KarloAldrete wants to merge 19 commits into
PostHog:mainfrom
KarloAldrete:perf/renderer-perf-bundle
Closed

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

Conversation

@KarloAldrete

@KarloAldrete KarloAldrete commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Problem

Issue #2162 — renderer CPU & memory on real, heavy sessions. Two 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.

Changes

Three focused changes on the open-time and memory path:

  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. The rest is parsed in non-blocking chunks (parseSessionLogContentChunked, yields between slices) and swapped in.
  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. Evict inactive transcripts — free session.events ~20 s after a chat loses focus; rehydrate from disk (OS page cache) on return.

Scope note

An earlier version of this branch also bundled (a) a windowed open with on-demand scroll-up history and (b) the sidebar / Command-Center re-render fixes. Both are now out of this PR:

This keeps the PR to the open-time + memory story, which is what the title promises.

Benchmark (to be re-run on this reduced scope)

⚠️ The numbers below were measured on the earlier bundle. The open-time and evict-on-blur figures are driven by the changes that remain (tail-first parse, pre-freeze, eviction), so they should hold — but a fresh production A/B on exactly this scope is being re-run before these are treated as final. Note the steady-state RAM of a focused chat is no longer reduced (that came from the windowed open, now dropped).

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) — tail-first parse + pre-freeze:

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%)

Renderer heap when a chat loses focus — eviction:

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

No regression in idle CPU, DOM node count, or GPU/VRAM.

How did you test this?

  • Unit tests added with the changes: ensureEventsLoaded.test.ts, sessionLogs.chunked.test.ts, sessionStore.test.ts, useSessionEventsResidency.test.tsx.
  • pnpm --filter @posthog/core test1806 pass; pnpm --filter @posthog/ui test1074 pass; pnpm typecheck clean across all packages.
  • Production A/B benchmark measured via Chrome DevTools Protocol + /proc + /sys, forced GC before each heap read (being re-run for the reduced scope — see note above).
  • Manual: opened a 48k-event and a 96 MB chat; 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 thread packages/ui/src/features/sessions/components/VirtualizedList.tsx
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.

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.
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.
@KarloAldrete
KarloAldrete force-pushed the perf/renderer-perf-bundle branch from f027eea to c068451 Compare June 30, 2026 10:27
@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Reviews (2): Last reviewed commit: "Merge branch 'main' into perf/renderer-p..." | Re-trigger Greptile

Comment thread packages/core/src/sessions/sessionService.ts Outdated
…vents

ensureEventsLoaded reads the on-disk ndjson asynchronously while the session
stays live, so a streamed turn, an idle-blur eviction, or a second focus can
replace session.events mid-read. The old guard
(isPromptPending || events.length > 0) missed cases: a short turn that completes
before the read resolves leaves isPromptPending already false, and a pending
prompt whose optimistic bubble lives outside events wrongly blocked rehydration
(empty transcript). openTranscriptWindow also registered the scrollback cursor
before the commit decision, orphaning it when the commit bailed and tearing a
later scroll-up.

Root fix: sample the events array identity before the read and bail on commit if
it changed (immer never mutates in place, so identity is an exact
optimistic-concurrency token). Split openTranscriptWindow into a pure
computeTailWindow + setTranscriptWindow so the cursor is registered atomically
with the events write, never outliving a discarded seed.

Adds regression tests for the completed-turn race, the pending-prompt seed, and
the orphan-cursor case.

Generated-By: PostHog Code
Task-Id: 219001a5-6df3-4e41-8db8-d67eec62dd7d
Brings the PR branch up to date with main (Claude Sonnet 5, browser tabs,
onboarding, cloud-resume, offline UX, +50 more) and reconciles it with the
reworked windowed-transcript architecture on this branch:

- ensureEventsLoaded / resolveReconnectEvents keep the windowed design (tail +
  on-demand scrollback) and the array-identity rehydration guard, dropping the
  superseded deferredContent / parseSessionLogContentChunked / commitLoadedEvents
  path that main still carried.
- Re-applies the "remove unused chunked log parser" cleanup (6edfb0a) that the
  merge reintroduced.
- Keeps main's independent sessionService changes (steer, notifications,
  cloud-resume, skills, offline UX).

Generated-By: PostHog Code
Task-Id: 219001a5-6df3-4e41-8db8-d67eec62dd7d
Catch up with the 6 newest main commits (updates changelog, inbox action
tracking, agent-builder MCP connections, durable cloud-task streaming,
cross-fleet scout findings). Clean merge — no conflicts, sessions untouched.

Generated-By: PostHog Code
Task-Id: 219001a5-6df3-4e41-8db8-d67eec62dd7d
@KarloAldrete

Copy link
Copy Markdown
Contributor Author

Superseded by #3063, which landed the pre-freeze + eviction work (and explicitly absorbs this PR and #2710). Closing to keep the queue 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.

2 participants