Skip to content

Instantly share code, notes, and snippets.

@Hona
Created May 17, 2026 23:12
Show Gist options
  • Select an option

  • Save Hona/5c1e4ae2e7f2abfafa57f75757cfaa39 to your computer and use it in GitHub Desktop.

Select an option

Save Hona/5c1e4ae2e7f2abfafa57f75757cfaa39 to your computer and use it in GitHub Desktop.
OpenCodeDesktop performance session archive, findings, TLDR, and blog draft

OpenCodeDesktop performance work: findings, TL;DR, and blog draft

Generated: 2026-05-18 Source: local OpenCode session archive (opencode db) plus GitHub PR metadata via gh.

TL;DR

Before: measured stable session timeline loads were roughly 2-4 seconds, with real-world bad sessions reported around 5-15 seconds.

After: the main virtualized path reached sub-500ms first useful timeline render. Later cache/anchor work made the bottom-aligned visual load appear in about 113ms on the measured cold path.

Metric Before After
Stable baseline Timeline Ready ~1928ms ~385ms
Worse clean baseline run ~4098ms 1219-1494ms from quick wins alone
Cold bottom-aligned visual settle 807ms 113ms
Revisit/cache bottom rows visible 2417ms 612ms
Icon route/session switch INP median 857ms 618ms

Best headline sentence:

OpenCodeDesktop session timeline load went from roughly 2-4 seconds to sub-500ms first useful render, with later anchoring work making the bottom-aligned view appear in about 113ms on the measured cold path.


Evidence map

Core sessions found in the local OpenCode database:

Session Title Brisbane time
ses_1fa5b6ef9ffeVr5goC3tBBil8Y Electron tools: recording and analyzing Chrome performance trace May 8 13:32 -> May 9 09:38
ses_1f6097408ffejXNZZgCpgOWu8r perf: Virtualised infinite scroll May 9 09:40 -> May 12 11:47
ses_1ebf7fee7ffeWa8fn2oAzhe298 Opencode Timeline Perf: analysis & next steps planning May 11 08:35 -> 10:02
ses_1eaabe311ffe5Lu6XrDYa6ubF2 perf: Quick wins May 11 14:38 -> May 12 09:09
ses_1e62fb38dffegWEV7SJWQKxJXZ fix: Flicker on load May 12 11:32 -> 11:41
ses_1e62e1625ffe3NVdzAbse0nNZC fix: ResizeObserver crash May 12 11:34 -> May 15 12:11
ses_1dfcbc5d8ffe5CBmBKr292x6Qp refactor: icons thru a sprite May 13 17:19 -> 17:56

Cross-checked PRs:

PR Status Role
anomalyco/opencode#26282 merged ToolStatusTitle width measurement / FLIP attempt reverted
anomalyco/opencode#26619 open draft implementation-aware -> virtualization-aware smoke test
anomalyco/opencode#26781 closed draft first TanStack turn-level virtualization attempt
anomalyco/opencode#26813 closed deferred default-open heavy tool bodies / baseline hot path wins
anomalyco/opencode#26949 open main virtua row-level timeline virtualization PR
anomalyco/opencode#26950 merged icon sprite PR
Hona/opencode#3 merged into virtua branch restore virtual timeline cache
Hona/opencode#4 merged into virtua branch bottom anchor / cold load hint

Blog draft: I fixed OpenCodeDesktop using OpenCodeDesktop

For about a week, I worked on one problem: opening large sessions in OpenCodeDesktop felt terrible.

Sometimes switching into an old session took one or two seconds. Sometimes it was worse: five, ten, even fifteen seconds before the app felt settled. The failure mode was not subtle. The UI would visibly replay itself, flicker from top to bottom, jank while scrolling, or hit one-frame flashes when streaming content grew.

The fun part: I did not hand-write the fixes myself.

I fixed OpenCodeDesktop using OpenCodeDesktop.

Every meaningful step came through the app I was trying to optimize: agents, local database queries, Chrome DevTools MCP, Electron MCP, Playwright traces, draft PRs, smoke tests, perf traces, GitHub review, and a lot of “stop guessing, measure it”.

This is the timeline.


First: make profiling real

The first question was basic:

do the electron_ tools also include a method to record a chrome performance trace, then view and analyse the full data?

At first, the answer was no. The Electron MCP tools could inspect windows, DOM, logs, and state, but they did not expose Chrome tracing.

Then I added Chrome DevTools MCP. That changed the whole investigation.

Within minutes, OpenCode could attach DevTools to the Electron app over the remote debugging port. That meant I could record real Chrome performance traces of the actual desktop app while switching sessions.

The first useful trace gave a clear shape:

Metric Value
Large session switch INP 838ms
JS processing 804ms
Input delay 7ms
Presentation delay 27ms
Forced reflow total 361ms
DOM size 8,895 elements

The important part: the delay was not network. It was not input delay. It was the renderer doing synchronous work.

The first dumb hotspot was ToolStatusTitle.

It looked visually like static text: labels like “Explored”. But the component measured its rendered text width using Range.getBoundingClientRect(). During historical session mount, that forced layout repeatedly.

Measured cost:

Hotspot Cost
ToolStatusTitle.contentWidth() 187ms
Share of JS processing 23.3%
Share of full INP 22.3%
Share of forced reflow 51.8%

That was the first rule of the week:

Performance work is allowed to be boring. If 187ms disappears by not measuring old labels on mount, take the win.

A small PR deferred the measurement. A larger FLIP rewrite was attempted, then rejected as too much. The final change stayed small.

After that, ToolStatusTitle/contentWidth disappeared from the trace: 0ms.

But the session was still slow.


The second trace humbled the first fix

After the title fix, a proper DevTools trace on the stable dribble.tf testbed showed:

Metric Value
INP 1662ms
Processing 1627ms
Click task 1618.4ms
ToolStatusTitle/contentWidth 0ms
Forced reflow total 1434ms
solid-presence getAnimationName 1175ms
DOM elements 26,193

This was the second rule:

Removing a hotspot does not mean the app is fast. It means the next bottleneck is finally visible.

The next bottleneck was Kobalte / solid-presence doing animation-presence checks during historical mount. It was reading computed style and animation state across a huge DOM. That work made sense for live animated UI. It did not make sense for rendering a giant historical chat transcript.

At this point, the perf work became disciplined:

  • pick a stable session, not the live session I kept adding messages to
  • use the same source session and target session
  • reset zoom
  • normalize viewport
  • mark “Timeline Ready”
  • mark “DOM Settled”
  • save traces every time

The chosen testbed became a large dribble.tf session:

GitHub CLI: sticky jump movement & physics analysis for Momentum Mod project

The benchmark repo tracked two north-star metrics:

Metric Meaning
Timeline Ready click -> first useful target timeline row exists
DOM Settled click -> DOM quiet, excluding quiet-window padding

Secondary metrics included TBT, Max Long Task, Main Thread Work, and Worker Main Handoff.


The dangerous phase: fast numbers that were wrong

Early windowing experiments produced seductive numbers.

One run hit:

Metric Value
Timeline Ready 71.6ms
DOM Settled 137.6ms
TBT 14.1ms

Another fake part-windowing experiment got first useful render down to 76.8ms.

But the UI was wrong.

There was flicker. There were weird scrollbars. The viewport could show unloaded gaps. There were proposed “Load details” / “Show earlier steps” controls. Those were rejected.

The core constraint became explicit:

No benchmark-only hacks.
No fake controls.
No crude spacer pretending full history height is known.
The UI must look the same.

That constraint saved the work.

The goal was not to win a benchmark. The goal was production-grade session timeline performance.


First real attempt: TanStack Virtual

The first serious virtualizer attempt used @tanstack/solid-virtual.

That made sense. OpenCode already used TanStack, Solid support existed, and TanStack Virtual is a respected low-level engine.

A draft PR was created:

#26781perf(app): virtualize session timeline turns

PR body summary:

  • turn-level session timeline virtualizer
  • hash reveal through virtual window
  • reduced timeline/dev overhead
  • smoke path passing
  • guardrail still failing on long tasks

Headline numbers looked promising:

Metric Baseline Perf branch
Timeline Ready 1928ms 321ms
DOM Settled 2057ms 1349ms
TBT 1603ms 354ms

But the branch felt bad.

The headed smoke test and traces showed 1-2 FPS states, scroll bounce, hash reveal trouble, huge long tasks, and layout thrashing.

The likely causes included:

  • stale data-index
  • virtualizer measurement contention
  • nested diff virtualizer contention
  • scroll anchoring disabled or fighting the app
  • row object identity churn
  • turn-level virtualization being the wrong abstraction

The key realization:

A “turn” is not the real render unit.

One user message can produce one assistant response, or it can produce 80 tool calls, edits, shell outputs, diffs, and streaming parts. Virtualizing by turn means a single virtual row can still contain hours of content.

OpenCode’s real data model is messages and parts. The UI concept of a “turn” is derived.

That meant the timeline had to move lower.


The guardrail: smoke testing the whole session

Before continuing with virtualization, a smoke test was built.

Draft PR:

#26619test(app): add session timeline smoke coverage

The test generated a representative session fixture covering:

  • text
  • reasoning
  • grouped context tools
  • edits
  • shell
  • web
  • question
  • task parts

It then scrolled through the timeline and asserted that mounted / visible parts appeared in order.

This mattered because virtualization bugs often pass normal tests. The DOM is allowed to unmount offscreen content, so the test had to become virtualization-aware without becoming implementation-aware.

The final direction was:

  • do not require all offscreen tools to be mounted immediately
  • scroll through the whole history
  • accumulate what becomes visible/mounted
  • assert order
  • assert product behavior, not implementation internals

That smoke test became the safety net for the actual virtualization branch.


The switch: Virtua

After TanStack, the work shifted to virtua.

The reasoning was practical. virtua had Solid support and primitives that better matched chat/timeline needs:

  • dynamic heights
  • reverse/prepend behavior
  • shift
  • scrollRef
  • keepMounted
  • automatic measurement through ResizeObserver

The first virtua pass was still turn-level. It mounted only three rows, but first content was still around 1.5-1.6s.

Then the quick-win deferred tool-body PR was layered in.

PR:

#26813perf(ui): defer default-open tool bodies

PR numbers:

Metric Clean baseline With PR
Timeline Ready ~4098ms 1219-1494ms
Max Long Task ~3267ms 1157-1414ms

This changed default-open heavy tool bodies so they no longer mounted during initial session render. Importantly, the initial implementation mounted deferred bodies FIFO, but the bottom of the timeline matters most. The queue was changed so the newest/bottom content mounted first.

That was a good example of product-aware performance work: not “render less forever”, but “render what the user is actually looking at first”.

Then virtua moved to row/part-level.

Main PR:

#26949perf(app): virtualize session timeline rows

PR summary:

  • upgrade virtua
  • virtualize at timeline-row granularity, not full turns
  • flatten loaded user/assistant messages into row keys
  • preserve message anchors and hash reveal
  • keep prepend loading, context groups, diffs, retry/thinking/error rows
  • keep existing message-page API for now
  • add TODOs for future cursor-paged timeline parts

This was the real architectural turn.

The app was still loading message pages from the existing API, but the UI stopped treating whole turns as the virtual row. It built a flattened row model over messages and parts.

The smoke test passed on both branches:

Branch Smoke result
latest upstream/dev 2.3m
virtua branch 1.4m

Quick win: icons through a sprite

While profiling route/session switch INP, another hotspot appeared: icons.

The app rendered inline SVGs by injecting innerHTML per icon. In traces, icon rendering showed up as a measurable synchronous CPU bucket.

PR:

#26950perf(ui): render icons through an svg sprite

Benchmark from the PR:

Metric upstream/dev PR Delta
INP median 857ms 618ms -28%
Icon CPU median 44ms 18ms -59%

Follow-up validation found the per-icon getElementById concern was real but tiny: roughly 0.2-0.3ms across ~200 lookups. A follow-up cached repeated lookup work.

Smoke validation later showed:

Check Value
Sprite symbols 95
Icon uses 129
Unresolved uses 0
Lookup after transition 0

This was not the whole performance story. But it was a clean measured win.


The crash: ResizeObserver null window

Then came the stability blocker.

Creating a new session / sending a prompt could crash with:

TypeError: Cannot read properties of null (reading 'ResizeObserver')

Root cause: virtua@0.49.1 called through to something equivalent to:

getCurrentWindow(getCurrentDocument(element)).ResizeObserver

But in the crash case, ownerDocument.defaultView was null.

So the virtualizer was observing a root tied to a document/window that no longer existed.

The fix was not to patch around random symptoms. The branch was refactored so the virtualizer only mounted when it had a valid connected scroll root. This landed as:

  • dcbe29c7c6fix(app): stabilize session timeline virtualization
  • 12c6c0925ffix(app): guard virtualizer scroll root

This made the virtualization usable instead of just fast.


The flicker: cache, bottom anchor, and streaming growth

The next class of bugs was more subtle: flicker.

On session load, the timeline could briefly start at the top or at a bad estimated height, then correct downward as measurements arrived. To a human, it looked like the app was replaying the session from top to bottom.

The first fix restored measured virtual timeline cache.

Fork PR:

Hona/opencode#3fix(app): restore virtual timeline cache

Measured result:

Metric Baseline Cache PR
Time to bottom rows after route ~2417ms ~612ms
Height correction 4160 -> 10306 -> 12598 -> 13622 11477 stable

Then the branch anchored initial mount to the bottom when in normal follow-bottom mode.

Fork PR:

Hona/opencode#4fix(app): anchor virtual timeline to bottom

Cold-load result:

Metric Baseline PR
First bottom-aligned sample 807ms 113ms
rAF samples at scrollTop=0 1 0
Height correction 2600 -> 9969 -> 11488 -> 12126 3900 -> 8444

This removed the obvious load-time top-position flash.

But streaming introduced another version of the same problem.

When AI text streamed and wrapped to a new line, the row height changed. When shell/edit/tool rows expanded from a compact pending state into full content, the row height changed more dramatically.

Headless repros measured it:

Scenario Before
Text streaming one-frame 34-35px bottom gaps
Shell expansion maxBottomGap: 132, framesOver8: 1
Edit insertion maxBottomGap: 2081, framesOver8: 1

The right product behavior was simple:

If the user is bottom-anchored and content grows at the bottom, keep them at the bottom.

For text streaming, using the public virtua API to scroll the last row into align: "end" on active content changes got:

Metric After
maxBottomGap 0
framesOver8 0

For shell/edit expansion, a small commented workaround for virtua issue #301 was accepted: a short rAF bottom-lock loop while measured content resized.

After validation:

Scenario After
Shell expansion maxBottomGap: 0, framesOver8: 0
Edit expansion maxBottomGap: 0, framesOver8: 0

This is the one place I would explicitly call out as a workaround, but it was not hidden: it was commented, tied to an upstream virtua issue, measured before/after, and narrowly scoped.


The state bug: rows remounted when data changed

Another blocker was collapse state.

If a tool or edit was visible and I manually collapsed it, a new streamed part could cause that row to remount and reset to the default open state.

Root cause: keyed rendering was tied to changing row object identity / metadata instead of stable row identity.

A row could change from something like:

lastAssistantPart: true

to:

lastAssistantPart: false

and Solid would treat it as a new keyed child.

The fix was stable row identity.

Commits included:

  • 4a72af3ed7fix(app): preserve virtual timeline row state
  • 9b59d5fb31fix(app): preserve timeline row state

The stated product goal was narrow and correct:

I do not care about collapse state after unmounting by scrolling away.
I care that if it is on screen and currently rendered, a new chat message should not reset it.

That is exactly the kind of constraint that keeps virtualization sane.


What did not work

The most useful parts of this week were not just the wins. They were the rejected paths.

Rejected: profiling the active session

The active OpenCode session kept changing because I kept prompting in it. That polluted comparisons. The benchmark moved to a stable dribble.tf session.

Rejected: bigger FLIP rewrite

A larger animation rewrite for ToolStatusTitle was created, then reverted. It was too much for the measured problem.

Rejected: fake “Load details”

Any visible control that changed the product behavior was rejected.

Rejected: fake spacer/window hacks

They hit amazing numbers — even 76.8ms first useful render — but made the scrollbar and viewport wrong.

Rejected: TanStack as the final implementation

The TanStack branch produced good headline numbers, but failed the actual product feel: 1-2 FPS, bounce, long tasks, hash reveal issues.

Rejected: min-height guessing as the flicker fix

A min-height guess could reduce some correction, but it also caused visible buffers and became maintenance debt. The better model was bottom-locking when the user is already at the bottom.

Rejected: SDK/EventV2 workaround

When upstream SDK/event changes broke typecheck, the tempting local workaround was rejected. The branch merged latest dev after the upstream fix instead.


Final timeline, compressed

All times Brisbane / AEST.

Time Milestone
May 8 13:32 Asked whether Electron tools could record Chrome traces
May 8 13:35 Chrome DevTools MCP configured
May 8 afternoon First trace: 838ms INP, 187ms in ToolStatusTitle
May 8 #26282 merged: defer tool status width measurement
May 8 evening Next trace: 1662ms INP, 1175ms solid-presence
May 9 09:40 Main virtualization session starts
May 9 Benchmark repo and metrics: Timeline Ready, DOM Settled
May 9 Early fast hacks rejected for flicker / bad scrollbar
May 9-11 TanStack attempt built, measured, draft PR #26781, later abandoned
May 10-11 Smoke test PR #26619 created
May 11 Quick-win PR #26813: deferred default-open tool bodies
May 12 Virtua branch PR #26949: row-level timeline virtualization
May 12 Icon sprite PR #26950: INP median 857ms -> 618ms
May 12 ResizeObserver crash root-caused
May 13 Cache and bottom-anchor fixes merged into virtua branch
May 14-15 Row state, streaming anchor, resize bottom-lock fixes
May 15 Latest upstream merged; SDK workaround reverted; branch healthy again

The meta lesson

The actual technical fix was not “use virtualization”.

The fix was building a truth pipeline:

  1. Attach DevTools to the real Electron renderer.
  2. Pick a stable session.
  3. Measure the same interaction repeatedly.
  4. Name the product metrics.
  5. Preserve screenshots and traces.
  6. Reject benchmark-only improvements.
  7. Add smoke coverage that encodes product behavior.
  8. Iterate on the smallest measured bottleneck.
  9. Keep using the app being fixed to fix itself.

The final architecture was not clever. It was less clever than the bad attempts.

  • Flat row model.
  • Stable keys.
  • Real measured DOM.
  • Bottom-follow as a first-class behavior.
  • No fake controls.
  • No fake fixed spacers.
  • Heavy content deferred only where product behavior allows it.
  • Cache measured row heights.
  • Let the virtualizer do virtualizer things.

That is the part I like most: the best version was the most boring one.

OpenCodeDesktop got faster because the process got stricter. Every time I guessed, I was wrong or half-right. Every time I measured, the next step was obvious.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment