How We Made Long Threads Reopen In 159 ms (Without Rewriting Storage)
A 40 MB agent thread used to take ~590 ms to reopen in Nucleus. Now it takes 159 ms. Six pull requests, one harness, and the guesses that turned out to be wrong.
· Agrim · 8 min read
Written by Atlas
Description: measure first, then delete the work nobody asked for
Coming back to a long agent thread in Nucleus used to take about 590 ms.
Now it takes 159 ms.
We didn't get there with a new database, a new architecture or a faster machine. We got there by finding work the app was doing on every open that nobody needed, and deleting it.
This is how, with the numbers, including the parts that didn't work.
The usual playbook
The standard way to make long agent threads open fast comes down to three moves:
- First page only: the client gets the last few user turns, and older turns load on scroll.
- No tool bodies: command output, diffs and MCP results stay on the server until you expand a row.
- Cache first: the thread paints from a local copy, then catches up.
Good ideas.
Then we measured our own data, and two of the three didn't fit.
Measure your own data before you copy anyone's
Nucleus keeps each sub-chat's whole transcript as one JSON value in SQLite. On my own install that is 3,245 sub-chats and 2.42 GB of transcript. The median sub-chat is 104 KB. The 90th percentile is 1.5 MB. The largest is 41.7 MB.
Here's where those bytes go:
- Tool outputs: 61.5%
- Tool inputs: 20.5%
- Codex turn diffs: 6.2%
- Tool envelopes: 6.1%
- Assistant text: 3.3%
The prose you actually read is about 3% of what the app loads.
And the shape is the opposite of a chat app. Our heavy threads don't have many turns. They have a few turns with thousands of tool calls inside each one. One assistant turn on my machine is 22 MB.
So "only send the last 10 turns" does nothing for us. I checked. Windowing to the last 15 user turns still ships 100% of the bytes at the 90th percentile.
Copying the headline trick would have cost a week and moved nothing.
The second move, leaving tool bodies on the server, does fit. That's the 82%.
Build the ruler before you cut
Before changing anything, we built a harness. It's bun run perf:thread-open in the Nucleus repo.
It builds a scratch profile with a synthetic 39.5 MB thread shaped like the real ones (230 messages, 25,600 parts, mostly Bash, Read, Write and Edit). It launches a real build of the app, signs in as a fake user so nothing ever syncs, and clicks through it over the Chrome DevTools Protocol:
- Cold: fresh process, open the heavy thread.
- Warm: open a small chat, then come back.
- Tab switch: move between sub-chats inside the thread.
For each it records time from click to the last message group being on screen, bytes over IPC, main-process time, the longest renderer task and heap.
On current dev, cold open took 1,238 ms with 39.5 MB crossing IPC. A warm return took 571 ms.
That warm number was the interesting one.
Coming back to a chat sent almost nothing over IPC, 1.8 KB. And it still took more than half a second, almost all of it in one renderer task.
The data wasn't the problem there. Something was redoing work.
What we changed, layer by layer
We kept the storage exactly as it is, one blob per sub-chat. No event log, no per-message rows. Every change had to leave what you see on screen identical. No animation cut, no poll slowed, no "Load earlier messages" changed.
The warm return, measured as each layer was merged on top of the last (the last step was measured in its own A/B session, against 340 ms):
- Harness only: 593 ms
- Main-process write path: 577 ms
- Parallel loading: 578 ms
- Renderer first paint: 441 ms
- Skeleton transcripts: 351 ms
- Keep recent chats warm: 159 ms
Here's what each one is.
1) Stop re-reading the blob in the main process
chats.get re-counted every changed sub-chat's messages with json_array_length on every open. The sidebar's 5-second polls re-parsed whole transcripts to compute file stats and a plan-approval flag.
All three now come from a cache filled when the transcript is written. The write path already has the parsed array in hand.
- Message count after a write: 15.5 ms → 0.2 ms
- Sidebar poll after a write: 45.6 ms → 0.006 ms
We also found a migration had quietly added two columns that live after the transcript in each row. SQLite stores rows in column order, so reading either one meant walking a 40 MB value. A covering index fixed it: 25 ms → 0.016 ms for a synthetic chat with 24 large sub-chats.
None of this moves the warm number, because it's write-path work. It does stop the main process stalling while an agent is streaming.
2) Start loading before you know you need to
Opening a chat was a waterfall: fetch the chat metadata, wait for React to commit, find the active tab, then fetch its messages.
Now the messages fetch starts the moment a chat is selected, alongside the metadata. A refetch of a sub-chat that hasn't changed returns 61 bytes saying so, instead of re-sending 43 MB.
The harness can't see this one, because every scene is a first fetch. It's in for the real app, where you go back and forth all day.
3) Do less before the first paint
The renderer scanned the whole transcript on every streaming tick, three separate times, and split the same markdown into blocks twice per mount.
Scans now skip messages they've already seen. Settled markdown is split once and cached.
- Work per streaming tick: about 4.3 ms less
- Markdown split when a tab remounts: 4.1 ms → 0.05 ms
- Warm return: 593 ms → 441 ms
4) Skeleton transcripts
This is the second move from the playbook, adapted to how we store things.
When you open a thread, the big tool bodies (Bash output, Read contents, Write and Edit payloads, Grep and WebFetch results, generic MCP output) stay in SQLite. The renderer gets a skeleton with everything each collapsed card shows. Expand a card and its body loads.
It's opt-in by tool, not opt-out. Anything a card renders fully without being expanded stays inline: visuals, plans, todos, Spaces cards, Studio, git output that feeds the commit badges.
The dangerous part is writing. The renderer saves the transcript back to SQLite at the end of every turn. If a skeleton were ever written back, a tool body would be gone. So the main process restores every missing body from the stored row before any save, and refuses the save outright if it can't find one.
The tests check the stored transcript is byte-identical after a round trip.
- Bytes over IPC on open: 39.5 MB → 15.4 MB
- Renderer heap after the warm scene: 272 MB → 152 MB
- Warm return: 441 ms → 351 ms
5) Keep the last few chats in memory
The profile of that warm return was the biggest surprise of the whole project.
About 250 of its 300 ms weren't React. They were the changed-files card re-running a line diff over every Edit in the transcript, every time the chat reopened. The same edit was also diffed again by its message badge and by its card.
Diffs are now computed once per tool call and remembered.
We also found the code meant to free a chat's memory when you switch away never ran. The chat view remounts per chat, so its "previous chat" check always saw the new one. That's why memory kept climbing across a session.
Now the last three chats stay loaded, with a 32 MB cap on the ones you've left.
- Warm return: 340 ms → 159 ms
- Tab back: 59 ms → 35 ms
- Heap after 10 chat switches: 212 MB and climbing → 145 MB and flat
What didn't work
The first version of skeleton transcripts made things worse in a way the renderer numbers hid.
Building the skeleton meant parsing a 40 MB blob in the main process. Main-process time per heavy open went from 9 ms to 112 ms. The renderer got faster. Everything else in the app waited behind it.
My first fix was a cache keyed on the row's version. It did nothing. The first read after a write always misses, and that's exactly the read you care about.
The real fix was moving the work to a worker thread. The main process reads the row as raw bytes and hands the buffer over without copying. Main-process time went back to 10.2 ms.
Two more honest notes:
- The skeleton ships 39% of the bytes, not the 30% I hoped for. Keeping every card pixel-identical means a lot stays inline.
- Cold open is still noisy on a busy machine. In runs alternated against the previous build, the cold-open median went from 1.64 s to 1.16 s. A separate run of the same build landed anywhere from 1.2 s to 7.7 s while other builds were going. So I'm not claiming a cold number yet.
What I'd tell you to do
- Build the harness first. Every claim above came from one, and two of my guesses were wrong.
- Profile the boring case. Our worst offender was a warm reopen with nothing new to load.
- Measure your own data's shape before borrowing a trick.
- Watch the main process, not just the frame you're staring at.
- Make the dangerous write impossible, then test that it's impossible.
All six changes are in review in the Nucleus repo and will ship in an upcoming Insider build.
Thank you for reading.