Chapter 13 · Engineering culture
Performance as a feature
How T3 Code turns "keep it fast" into byte budgets that fail tests, a compiler that does the memoizing, render counts you can diff, and profilers that cost nothing when off.
Most solo projects treat speed as something you fix later, when it hurts. T3 Code treats it as part of the product. The maintainers name performance as one of four things they “can never compromise on”, and they back that with tests that fail when a change sends too many bytes, with a lint rule that bans hand-written memoization, and with scripts that count React renders so a pull request can show a number instead of a feeling.
This chapter walks through those mechanisms with the real code, follows one merged performance fix from bug to proof, and then compares all of it with the performance work in your island portfolio, monosodiums. You do not need to know Effect or the rest of the T3 Code server to follow it.
Name the usual causes before they happen
TL;DR
T3 Code’s instructions name the three things that usually make the app slow: too much data over the socket, animations that keep the GPU busy, and long lists. Naming them up front means every change gets checked against them.
The root AGENTS.md is the file every agent and contributor reads first. Its second pillar is about speed, and it does something most “be fast” guidance skips. It lists the causes the team has actually hit:
### 2. Performance without compromise
Lots of apps have gotten bogged down with bad tech decisions and "slop". We have not, and we're proud of the performance of T3 Code. We regularly audit for performance regressions, often caused by sending too much data over websockets, css animations causing gpu spikes, lists being hard to render, and more. Make sure all changes are considerate of performance impact.Each named cause matches a real constraint of the app. T3 Code is a GUI for coding agents. A thread can hold hundreds of tool calls, each with command output, and the server pushes all of it to web, desktop, and mobile clients over a WebSocket. Users leave it open all day. So a byte you add to every event is multiplied by every event, every client, and every hour.
The same file has a rule in its taste section that makes the GPU point concrete. It says “No continuously repainting animations; they peg the GPU on high-refresh displays” (AGENTS.md line 208). The mobile app has its own version of that lesson. The performance regressions doc explains that a looping Reanimated animation can starve React Native’s commits until the screen freezes, and it names the file that shows the safe pattern.
A list of causes is cheap to write. What makes it useful is that each cause has a check behind it, which the next three sections cover.
Budget the bytes on the wire
TL;DR
The server strips big tool output before sending events, and tests assert exact byte counts for the result. Every pull request gets a bot comment comparing its transfer sizes with main, and the budgets are set just tight enough that undoing any one optimization fails.
A regression in wire size is invisible on a fast laptop with a short thread. It shows up on a phone, on a long thread, over a relay. T3 Code handles this in three layers.
Write the rules down
docs/internals/performance-regressions.md lists the invariants the transport must keep. For example, cold opens and history pages stop at about 1 MiB of encoded rows. Another rule says “raw command output, dynamic-tool result bodies, and inline file-change bodies are omitted at the wire boundary, including small outputs.” Live command output only reaches a client that has expanded that command’s row, through a separate subscription. The doc also says where the proof belongs, in one line. “Payload budgets belong in these tests so regressions fail locally” (line 67).
Assert the bytes in a unit test
Here is one of those tests. It builds a finished command with 8 KiB of output, runs it through projectDomainEventForWire (the function that shapes events for the wire), encodes both versions exactly as the RPC layer would, and compares sizes:
const OUTPUT_BYTES_PER_ROW = 8_192;
// ...
const PRE_OMISSION_COMMAND_EVENT_RPC_JSON_BYTES = 8_790;
const MAX_PROJECTED_COMMAND_EVENT_RPC_JSON_BYTES = 1_024;
// ...
expect(preOmissionRpcJsonBytes).toBe(PRE_OMISSION_COMMAND_EVENT_RPC_JSON_BYTES);
expect(projectedRpcJsonBytes).toBeLessThanOrEqual(MAX_PROJECTED_COMMAND_EVENT_RPC_JSON_BYTES);
expect(projectedRpcJsonBytes / preOmissionRpcJsonBytes).toBeLessThanOrEqual(0.1);
expect(sourceItem).toHaveProperty("output", "x".repeat(OUTPUT_BYTES_PER_ROW));
expect(projectedEvent.payload).not.toHaveProperty("output");Read the assertions in order. The first pins the “before” size exactly, so if someone changes the fixture the test notices. The next two say the wire version is at most 1 KiB and at most 10% of the original. The last two say the persisted item still has its output and only the wire copy lost it. That last pair matters. The fix for “too many bytes” must not delete data the user needs later.
This file is one of sixteen in the test:perf:v2-wire script in the root package.json. The script groups the server projection, paging, and stream tests with the client reducers that consume them, so one command checks both ends of the wire.
Report every pull request against main
The third layer runs on every pull request. An integration test boots a real server, plays a scenario with ten historical turns full of tool output, and measures what crosses the socket. Its budgets carry comments that explain how each number was picked:
// The measured turn decodes to about 22 KB; without live body dedupe its repeated answer makes it 26 KB.
const TRANSFER_BUDGET = {
totalWireBytes: 7_000,
threadSnapshotWireBytes: 5_000,
measuredTurnWebSocketWireBytes: 2_000,
measuredTurnWebSocketDecodedBytes: 25_000,
measuredTurnWebSocketMessages: 8,
} satisfies ProviderTransferBudget;
// ...
// Decoded bytes, not wire bytes: phones parse and keep the decoded JSON. About 7% above the
// observed 103 KB, so a cold open without body dedupe (117 KB), the ID table, or row budgets
// (all 920 rows) fails. Kept apart from TRANSFER_BUDGET because the PR comment validates its keys.
const LONG_TURN_BUDGET = {
decodedBytes: 110_000,
rows: 75,
};This is the most transferable idea in the chapter. A budget is not “a number that seems big enough.” Each one sits a little above today’s measurement and below the size you would get if any single optimization were removed. The 25,000 decoded-byte ceiling sits between today’s 22 KB and the 26 KB you would get without deduplication. So the budget fails exactly when someone undoes work, and not before.
The CI server shards run this test on every pull request with caching turned off for it (ci.yml), and a follow-up workflow, thread-transfer-report.yml, posts a table on the pull request comparing each metric with the latest main result. On #35130 that comment reads “Thread transfer remains within every enforced ceiling” and lists, for example, a 3.8 KiB thread snapshot against a 4.9 KiB ceiling. The aggregate CI check even fails if the server tests ran and the measurement file is missing (ci.yml lines 680-687), so the report cannot silently disappear.
Let the compiler memoize, and count renders to prove it
TL;DR
T3 Code bans useMemo and useCallback because the React Compiler does that caching for them, and a test fails if the compiler skips any file. A script counts how many components re-render in fixed scenarios, so a fix can prove that a thread switch now re-renders 2 sidebar rows instead of all of them.
A React component re-renders when its parent re-renders, unless it is wrapped in memo and all its props are unchanged. Keeping props unchanged used to mean wrapping values in useMemo and functions in useCallback by hand. This caching is called memoization. It stores a result so React can skip work. Hand-written memoization breaks without warning, because one forgotten dependency or one new inline function undoes it.
The React Compiler is a build step that adds this caching for you. It analyzes each component and hook, and if it can prove the code follows React’s rules, it memoizes values automatically. Its internal codename was “Forget”, which is why compiled components show up with a Forget( prefix in their names. T3 Code’s render counter strips that prefix when it reports component names (renderCounter.ts).
What no-manual-memo implies
T3 Code has its own lint rule plugin for oxlint. One rule, t3code/no-manual-memo, reports any useMemo or useCallback call:
CallExpression(node) {
if (allowMemoHooks) return;
const name = manualMemoHookName(node.callee);
if (name === null) return;
context.report({
node: node.callee,
message: `Drop ${name}: the React Compiler memoizes this file. Write the plain value or function, and use useStableCallback only where a handler must keep one identity across renders.`,
});The rule only makes sense if the compiler really runs on every file. That is the implication, and the repo checks it. The web UI doc says “Every file under apps/web/src must compile.” When the compiler cannot prove a function is safe, it skips that function without a build error, and the app gets slower without anyone noticing. Lint catches most skips, but not all. So a test compiles every web source file and fails on any skip:
const compile = async (file: string) => {
const absolute = NodePath.join(repoRoot, file);
const result = await transform(absolute, NodeFS.readFileSync(absolute, "utf8"), {
jsx: { runtime: "automatic", development: false, refresh: false },
reactCompiler: { reportDiagnostics: true },
});
const bailouts = result.errors.map((error) => error.codeframe ?? error.message);
expect(bailouts).toEqual([]);
};The rule also bans the "use memo" directive, which forces the compiler to cache something. The rule’s comment says a forced cache “hides the ordering or structure problem that withheld it.” The mobile app still allows the two hooks through an allowMemoHooks option while its compiler coverage catches up (vite.config.ts). Julius Marminge added the rule in #20292, titled “fail lint when the React Compiler skips a listed file.”
Count renders instead of guessing
scripts/render-metrics.ts runs a set of browser scenarios: mount a thread, stream an assistant turn 30 deltas long, switch threads, type in the composer. A setup file hooks into React’s DevTools interface and counts which components did work on each commit. The script writes one JSON file per run, and compare prints a table of differences between two runs. By default it runs three times and fails unless every run produced identical counts. Render counts, unlike milliseconds, should not change from run to run, so a difference means the scenario itself is unreliable.
The scenarios wait for the tree to go quiet before and after each counted window (renderScenarios.renders.tsx), and they mount each tree once uncounted first so fonts and lazy modules are already loaded. These are the kinds of details that make a measurement repeatable. Note that the renders project runs only through this script, not in CI (apps/web/vite.config.ts). It is a tool for a person proving a change, not a gate.
One fix, before and after
#29150, by Yuruzuu, is titled “switching threads no longer re-renders every sidebar row.” The description opens with the problem. “Switching threads re-rendered every row in the sidebar, even though only the rows losing and gaining the active highlight change.”
Before the fix, Sidebar.tsx declared attemptSnooze as a plain arrow function. The PR explains that the compiler left that function uncached, so it was a new function on every render. It was passed to every memoized SidebarThreadRow as onSnooze, which “defeated memo for all rows.” The commit diff shows the whole change. After it, the handler is wrapped in useStableCallback, a small hook that returns one function for the life of the component and forwards calls to the latest version:
const attemptSnooze = useStableCallback(
(
threadRef: ScopedThreadRef,
preset: Pick<SnoozePreset, "snoozedUntil">,
opts: { coSnoozingKeys?: ReadonlySet<string> } = {},
) => {
void (async () => {
const outcome = await performSnooze(threadRef, preset, opts);
// ...
})();
},
);The proof is one new assertion in the app-level render scenario:
const windows = [await measure(go(B)), await measure(go(A))];
// Only the rows losing and gaining the active highlight change.
for (const window of windows) expect(window.components.SidebarThreadRow).toBe(2);
await report("app-switch-thread", windows);The PR says this assertion fails on main with “expected 4 to be 2” and passes on the branch. It then adds a timing table with an A/A control, meaning a second run of unchanged main to show how much the numbers wobble on their own. At 300 rows, renders per switch fell from 7,507 to 1,537 and median task time from 117.0 ms to 67.7 ms. At 4 rows the PR calls the result “a tie, within control noise.” It also lists what was not checked, which was “a pass in a real client.” That honesty is part of the method. The render count is the claim, and the timing is labeled with its limits.
Make profiling free when nobody is looking
TL;DR
T3 Code’s profilers are opt-in scripts that record a startup trace you can open later. The resource monitor that tracks agent processes only samples while someone has the diagnostics view open, so measuring never becomes its own slowdown.
Two more tools show a pattern worth copying. Neither is a test. Both are built so they cost nothing until you ask for them.
scripts/profile-startup.mjs runs the dev server with itself preloaded through NODE_OPTIONS. It starts the V8 CPU profiler, times every module resolve and load through Node’s module hooks, and wraps execFileSync so slow subprocess calls during startup show up by name. After 20 seconds or at exit it writes a .cpuprofile and a trace file per process. The timer that ends capture is unref()’d, a Node call that tells the process not to stay alive just for that timer. The comment beside it gives the reason. “Bound the capture without keeping dev servers or short-lived helpers alive.”
The resource monitor in native/resource-monitor is a small Rust program that reads process CPU and memory for the server and every agent it spawns. The resource telemetry doc explains its cost rules. It samples on a timer only while diagnostics has live subscribers. With none, the server sends a sample interval of 0, “so an idle server never walks the process table.” Linux per-thread enumeration is off because walking every /proc/<pid>/task directory “makes sampling itself expensive.” Its history is bounded by age, count, rows, and bytes, because “a count limit alone cannot bound memory when command lines vary in size.”
The lesson to copy is that a measurement tool runs inside the thing it measures, so the tool needs a budget of its own.
How this compares with monosodiums
TL;DR
Your island’s perf work is already careful: paired before-and-after runs, a check that rejects software rendering, rejected experiments written down. What T3 Code adds is turning the result into a test that fails later. For a solo portfolio, one or two of those guards is enough.
Your monosodiums repo has more measurement discipline than most solo projects, and in a few places it matches T3 Code’s habits closely.
scripts/perf.mjs launches a fresh browser per scene, refuses to measure on a software renderer, and throws on any page error or missing rendered frames. scripts/perf-pair.mjs alternates before and after builds so machine noise hits both sides equally:
for (let run = 0; run < Number(process.env.PERF_RUNS ?? 5); run++) {
for (const [side, build] of [['before', before], ['after', after]]) {
const file = join(output, `${side}-${run}.json`);docs/perf/results.md reports medians with ranges, keeps the raw CSV, and has a “Rejected changes” table. That table matters. “Batch animated plants with BatchedMesh” slowed all five pairs and was reverted, which is the same honesty as #29150’s “a tie, within control noise.” Your commit 51651ef puts its measured result in the message, “median mean frame time 116.47 to 40.46 ms.” Your check-render-quality.mjs asserts the adaptive-resolution controller’s behavior with plain node:assert, which is a real regression test for the logic.
The difference is in what happens after the work merges. In T3 Code, the result of a perf fix becomes an assertion that fails as soon as someone breaks it again, such as a byte ceiling or a render count of 2. In monosodiums, the frame-time results live in a Markdown report. Nothing reruns them, and the repo has no CI at all. Lint and build run only when you run them, and Cloudflare Workers Builds deploys whatever lands on master. If a later feature doubles the draw calls, you find out by looking.
A frame-time gate in CI would be a bad idea for you. Your own report says the numbers come from a shared desktop GPU under normal use, and T3 Code avoids timing gates for the same reason. The useful middle ground is to gate the counts that do not wobble. Your harness already records renderer.info.render.calls, triangles, and programs. For a static view such as the night welcome scene, those counts barely move between runs, much like React render counts. In your own runs.csv, all five original desktop-night runs recorded 4,092 draw calls, and all fifteen later desktop-night runs recorded 2,645. A ceiling on them, set just above today’s value the way TRANSFER_BUDGET is, would catch the regression that matters most in your report (“draw submissions original to retained, 4092 to 2645”) without needing a quiet machine.
There is also a small cleanup. docs/perf-plan.md opens with “Delete this file in the last commit before the PR merges,” and the file is still in the repo after PR #24 merged.
Try it
TL;DR
Turn one of your perf wins into a check that fails if it regresses, budget one payload in fud, and count re-renders in one place that you suspect re-renders too much.
- Add a draw-call budget to the island. In
monosodiums, add acheck:budgetscript besidescripts/check-render-quality.mjs. Have it run thedesktop-nightscene fromscripts/perf.mjsonce withPERF_RUNS=1and assert thatcallsandprogramsstay at or below a ceiling about 5% above today’s values. Write a comment above each number saying what it was measured on and which past change would exceed it, asTransferBudgetReport.integration.tsdoes. To check it works, revert the static building merge from commit863aef3in a scratch branch and confirm the script fails. - Budget one response in fud. Pick the dashboard snapshot that
tests/snapshot.test.tscovers. Add an assertion that the JSON-encoded size of a realistic month stays under a fixed byte count, plus an assertion that a field you deliberately do not send is absent, the wayThreadTransportPerformance.test.tschecks thatoutputis missing. To check it works, add a large unused field to the response and confirm the test fails. - Count one component’s renders. In
vibe-idx/apps/web, pick a list component you suspect re-renders on every keystroke. Write a test that types five characters and counts its renders with React’s<Profiler onRender>callback. Assert the count you expect. If it fails, find the prop that changes identity on every render, the way #29150 foundonSnooze, fix it, and confirm the count drops.
For how T3 Code turns repeated review comments into rules like no-manual-memo, see Turning review comments into lint rules. For how pull requests carry this kind of evidence, see CI and pull request hygiene.