유대선
프로젝트로
·기술 회고·5

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 tree

One 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:

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

beforeafter
Lines reconciled per keystroke1,773~185
.map() passes per keystroke6 (timeline + 5 lists)0 outside the composer
Reply state ownerStartupView (god component)ReplyComposer (leaf)
capture git-poll cadence20 s × every service60 s × every service
Component vs 300-line budget5.9× overwithin budget

How to not do this again

  1. 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.
  2. Never bind a high-frequency input to a large component's state. A text field fires setState per 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.
  3. 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).
  4. 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.