One keystroke re-rendered 1,773 lines: the email reply lag, by the numbers
Typing a single character into the inbox reply box re-rendered the entire 1,773-line StartupView component — a 60-row timeline plus five unmemoized list maps — on every keystroke. The fix was to extract a ~185-line ReplyComposer with its own local state. This is a write-up of what was wrong, why it was written that way, the measurements, and the guardrail that prevents the next one.
The complaint was blunt and correct: "why is opening an email so slow, and the whole UI feels heavy." I had been chasing the machine (a system daemon storm — more on that below). The user cut through it: it got slow after a specific set of updates; before that it was light; the problem is in our code. He was right.
What was actually wrong
The inbox reply composer lived inside StartupView.tsx. Seven pieces of its
draft state — replyTo, replySubject, replyBody, replyBusy, replyMsg,
sendArmed, and the open inqOpen — were all useState on StartupView
itself.
StartupView.tsx is 1,773 lines (wc -l). Our own house rule
(CLAUDE.md) is "files ~300 lines max, split before." This component was
5.9× over budget.
So every single keystroke in the reply textarea did this:
onChange → setReplyBody(...) → StartupView re-renders top to bottom
├─ rebuild the 60-row activity timeline
├─ inquiries.map() (inbox list)
├─ blocked.map() ×2 (needs-you tiles, two branches)
├─ CLUSTERS.map() (the 7 area tiles)
├─ agents.map() (running agents)
└─ reconcile the entire 1,773-line JSX treeOne character typed = 1,773 lines reconciled + six .map() passes. Hold
down a sentence and you are doing that dozens of times a second on the heaviest
screen in the app.
The measurement that pointed the right way
Before touching code I sampled the running process. This mattered, because it killed my own wrong theory:
- Renderer (
WebContent) at idle: 0.4% CPU. - Rust main thread: parked in
mach_msg(the event loop), i.e. idle. - No
git/lsofstorm from the app. The always-on pollers I suspected (capturegit-poll, the cloud realtime worker, a 250 ms countdown ticker) each turned out to be cheap or gated when I actually opened them —git log -nreads commit objects, not the working tree; the realtime worker only runs while logged in; the 250 ms ticker only runs during a brief countdown.
The app at rest was light. The cost was interaction-time — paid entirely inside that per-keystroke re-render. If I had "optimized" the pollers I would have shipped nothing and still been slow.
(The separate "everything is sluggish" feeling was a genuine system problem,
measured at the same time: load average 68, filecoordinationd 169%,
fseventsd 100%, 15 Spotlight mdworker processes — the aftermath of
moving an 88 GB iCloud-synced repo. Real, but not our component. Two different
bugs wearing the same coat.)
Why it was written this way in the first place
Nobody sat down and designed a 1,773-line component. It accreted. The inbox started as a small panel with two fields, so putting the reply state on the parent was the path of least resistance. Then features piled on — timeline, area tiles, memory Q&A, agents, clusters — each adding a few hundred lines to the same file, and the reply state was never moved out. The component crossed 300 lines, then 800, then 1,773, and the per-keystroke cost grew silently with it because nothing re-renders visibly differently at 300 lines vs 1,773 — until your hardware is busy enough that the wasted work shows up as lag.
That is the trap: a god component degrades continuously and invisibly. There is no error, no warning, no single bad commit — just a slope.
The fix
Extract the composer into ReplyComposer.tsx (~185 lines) with its own
local state, rendered keyed by the inquiry id:
{inqOpen && (
<ReplyComposer
key={inqOpen.node_id}
inquiry={inqOpen}
startupId={startup.id}
onClose={() => setInqOpen(null)}
/>
)}Now setReplyBody is a state update inside ReplyComposer. Typing re-renders
~185 lines, not 1,773 — and the timeline, the area tiles, the agent list, none
of it touches the keystroke path anymore. The key={node_id} gives a fresh
composer per inquiry, so switching emails resets the draft with no re-init
effect. The async draft/send race guard (previously an inqIdRef) became a
plain aliveRef cleared on unmount.
While here, one honest battery tune: the capture git-poll ran every 20 s
for every connected service; bumped to 60 s (3× fewer wakeups, commits still
captured within a minute).
By the numbers
| before | after | |
|---|---|---|
| Lines reconciled per keystroke | 1,773 | ~185 |
.map() passes per keystroke | 6 (timeline + 5 lists) | 0 outside the composer |
| Reply state owner | StartupView (god component) | ReplyComposer (leaf) |
capture git-poll cadence | 20 s × every service | 60 s × every service |
| Component vs 300-line budget | 5.9× over | within budget |
How to not do this again
- The file-size budget is a performance guardrail, not a style nit. "~300 lines max" exists precisely because the lag lives in the components that blew past it. StartupView at 1,773 lines was the bug surface.
- Never bind a high-frequency input to a large component's state. A text
field fires
setStateper keystroke; if that state lives on a 1,000-line parent, every keystroke pays for the whole parent. Push frequent-update state down to a small leaf. - Measure before optimizing. Sampling showed the app was idle-light and the cost was the re-render — which redirected the fix from "throttle the pollers" (would have done nothing) to "isolate the input" (fixed it).
- A god component degrades on a slope, not a cliff. There is no failing test for "this is now too big to re-render cheaply." The only defense is the budget + splitting before it hurts.
Verified: tsc --noEmit clean, no dangling references to the removed state,
shipped in 0.2.36.