#1302 · sidebar-bootstrap ships 138KB and refetches wholesale on thread status changes
TL;DR
Plain-language framing. The web app's left sidebar (projects and their threads) is loaded with a single request, GET /api/v1/sidebar-bootstrap, which returns every unarchived, visible thread of every project with the full thread record (28 top-level keys, ~1 KB per thread). The server pushes "something about thread X changed" notifications over a WebSocket. For a status-changed notification (a thread going idle→active when a turn starts and active→idle when it ends) the app has exactly one thing it can do: throw the whole cached sidebar document away and download it again, because the notification carries no row data and the sidebar lives in one monolithic react-query entry.
On the base commit with the seeded 1,200-thread database (129 visible unarchived threads) I measured: 133,614 bytes (10,770 bytes gzip) per sidebar-bootstrap response; 2 full refetches per single message send (one at turn start, one at turn end) inside a 19-request fan-out, with 96% of the transferred bytes being the two bootstrap copies; spawning two threads produced 8 refetches (1.09 MB; the verifier's re-run: 6 / 819 KB); three concurrent one-line turns produced 4 refetches (546 KB) in my run and 2 (273 KB) in the verifier's, because react-query's in-flight dedupe absorbs transitions that land while a bootstrap fetch is still outstanding, so under concurrency the count is anywhere from 2 to 2×N per batch of N turns depending on timing. All of the issue's numbers reproduce within a few percent. The status change is delivered as a bare "dirty" flag (metadata only carries projectId), the app's status-changed rule invalidates sidebarNavigationQueryKey() globally with an immediate flush, and there is no scoping to the affected project or row.
Two things the issue gets slightly wrong: JSON parse of the document is cheap (≈1 ms even at 6× CPU throttling), and because react-query structural sharing keeps unchanged row identities, the wholesale refetch does not by itself re-render every sidebar row. The expensive re-render I measured (650–930 ms at 6× throttle in the dev build) is the reorder that happens whenever a thread becomes active and jumps to the top of its project, and it happens the same way whether the row arrives via refetch or patch. The durable cost of today's design is therefore the wire bytes plus latency per lifecycle event (11 KB gzip × 2 per turn × every active thread, on mobile links), one server-side full thread-list read per event, and the O(all threads) deep-compare on the client — not parse or render. Measured: the redundant refetch alone costs 20–45 ms of main-thread time at 6× throttle; a setQueryData patch of the same row costs ≈1 ms before render.
Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
sidebar-bootstrap returns ~138 KB (11.7 KB gzip) for 131 unarchived threads | Verified | 133,614 B / 10,770 B gzip for 129 visible unarchived threads on the seeded DB (bootstrap.json). The small delta is the seed's random visible/hidden split. |
| ~1.1 KB per thread across 29 fields; sidebar renders ~8 of them | Partially verified | 1,002 B per thread row; 28 top-level keys (20 thread + runtime{2} + activity{5} + 7 list-entry fields). A grep of apps/app/src/components/sidebar/ finds ~16 fields read (id, projectId, parentThreadId, activity, status, environmentId, title, titleFallback, sectionId, hasPendingInteraction, runtime, visibility, updatedAt, pinnedAt, environmentWorkspaceDisplayKind, environmentHostId), so "8" is low, but roughly half the shape is unused by the sidebar (sourceThreadId, originKind, originPluginId, archivedAt, deletedAt, lastReadAt, latestAttentionAt, createdAt, environmentName, environmentBranchName, …), plus per-project sources[] and defaultExecutionOptions. |
| Sending one message refetches the entire payload; refires on lifecycle transitions | Verified | Exp. A: two GET /api/v1/sidebar-bootstrap (134,865 B and 134,861 B) 1.36 s apart, at turn start and turn end, per single bb thread tell. The unit test below shows the invalidation is unconditional on status-changed. |
| With several concurrently active threads every turn start/stop reparses and re-renders the whole sidebar tree | Partially verified | Refetches multiply, but the multiplier is timing dependent (exp. B: 3 concurrent turns → 4 refetches in my run, 2 in the verifier's; spawning 2 threads → 8 in my run, 6 in the verifier's; the ceiling is 2 per turn, the floor is 2 per batch when all transitions land inside one in-flight fetch). Whole-tree re-render is not what happens: rows are memo'd and react-query structural sharing keeps unchanged row identities, so only the changed row and its ancestors re-render; the heavy render is the active-row reorder, which a patch would trigger too (exp. D). |
| Multiplies with the drawer mount cost in #1261 | Not tested | #1261 was closed by PR #1307 (virtualized sidebar, mobile drawer kept mounted). AppLayout still calls useSidebarNavigation() unconditionally, so the refetches happen with the drawer closed too. |
| Server time is fine (~8 ms warm); the cost is wire size, JSON parse and re-render on mobile CPUs | Partially verified | curl: 7.4 ms total. JSON.parse of the 136 KB body: 0.2 ms, 1.0 ms at 6× CPU throttle (exp. C): parse is negligible. Fetch+parse+structural-share of a redundant refetch: 20–45 ms at 6× throttle (exp. D). Wire size (and one server-side thread-list read per event) is the real per-event cost. |
Repro step 1: pnpm seed:perf | Broken while the dev server is running | The seed inserts 604 environments, 481 of them destroyed; the running server's sweep:destroyed-environment-prune deleted them mid-seed (dev.log: delete from "environments" where id in (…481 args)) and the seed died with SqliteError: FOREIGN KEY constraint failed at seed-perf-fixture.ts:1005. Stop the dev app first (pnpm dev:stop), then pnpm seed:perf -- --reset, then start it. The command's help says the opposite ("Start the dev app once before seeding"). |
| Comment: production 0.37.0 install with 1,071 unarchived threads (985 idle, 86 error) is affected | Unverifiable, consistent | Nothing in the code path is size-bounded; at ~1 KB per row that install downloads ~1 MB (≈85 KB gzip) twice per turn. |
| Push-driven patching would remove most of the ~10-request fan-out after a send | Partially verified | Observed 19 requests after one send (exp. A); the bootstrap is 2 of them but 96% of the bytes. The rest are timeline deltas, child/fork lists, PR state, read receipt; several are project-scoped list invalidations of the same rule (see #1303). |
Environment
- bb
16ceb3a54(main, 2026-08-18), worktree/home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-24. Dev instance: app:15464, server:23464, daemon:31464, data dir/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-24-3f1b3830f25d. Vite dev build of the app (unminified React dev runtime, so absolute CPU numbers are inflated versus production; ratios are what matter). - Linux 7.0.0-29-generic, node v24.18.0, codex-cli 0.147.0 (provider
codex, model gpt-5.6-sol), headless Chromium viadev-browser. - DB:
pnpm seed:perf -- --reset(12 projects, 1,200 threads, 402,298 events; 174 unarchived, 129 visible), plus projectproj_qk56xcanay(local path/tmp/1302-qa, hosthost_eg4qqky6xx) with threadsthr_mfe9vetq34,thr_m4e7y5crhi,thr_q93qsjsqnb. - CLI wrapper: 1302/repro/1302-bb.sh (set
BB_REPOto your worktree; it always evaluatesscripts/bb-dev-app envfrom that worktree, after unsetting every inheritedBB_*variable). Warning: a shell opened from inside a bb agent thread already exportsBB_SERVER_URL(the user's real instance on:38886) andBB_THREAD_ID; the first version of this wrapper honoured a pre-setBB_SERVER_URLand the verifier caught it listing the real instance's hosts. The fixed wrapper (and1302-wait-idle.sh) ignore inherited values and printbb -> <url>on stderr so you can see which server every command hits; useBB_DEV_SERVER_URL=…to override deliberately. Verified after the fix: withBB_SERVER_URL=http://127.0.0.1:38886exported,1302-bb.sh machine listprintedbb -> http://localhost:22777and returned only that instance's host (wrapper-isolation-check.txt). Browser scripts aredev-browserQuickJS/Playwright scripts; run them withdev-browser --headless run <file>. In the transcripts belowbbmeans the wrapper.
Minimal reproduction
- Build and seed (server must be stopped during seeding, see claims table):
pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build scripts/bb-dev-app current # once, so the seed can attach to the real host id pnpm dev:stop pnpm seed:perf -- --reset # 12 projects / 1200 threads / 402k events, ~13 s scripts/bb-dev-app current # note App/Server URLs; eval "$(scripts/bb-dev-app env)"
- Measure the payload:
$ curl -s $BB_SERVER_URL/api/v1/sidebar-bootstrap -o bootstrap.json -w "http=%{http_code} bytes=%{size_download} time=%{time_total}s\n" http=200 bytes=133614 time=0.007403s $ gzip -k9 bootstrap.json && ls -l bootstrap.json.gz -rw-rw-r-- 1 sawyer sawyer 10770 Aug 18 07:45 bootstrap.json.gz $ jq '{projects:(.projects|length), threads:([.projects[].threads|length]|add)}' bootstrap.json { "projects": 12, "threads": 129 } $ jq -c '.projects[0].threads[0]' bootstrap.json | wc -c 1002Expected (issue): a sidebar-sized row (title, status, project, unread, pinned, provider, branch, attention time). Actual: the fullThreadListEntry(see bootstrap.json): 28 top-level keys includingsourceThreadId,originPluginId,deletedAt,archivedAt,environmentBranchName, the fiveactivitycounters, plus each project'ssources[]anddefaultExecutionOptions. - Create a scratch project and one idle codex thread (host id from
bb machine list --json):mkdir -p /tmp/1302-qa && cd /tmp/1302-qa && git init -q && echo hi > README.md && git add . && git commit -qm init curl -s -X POST $BB_SERVER_URL/api/v1/projects -H 'content-type: application/json' \ -d '{"name":"qa1302","source":{"type":"local_path","path":"/tmp/1302-qa","hostId":"host_eg4qqky6xx"}}' # -> proj_qk56xcanay bb thread spawn --project proj_qk56xcanay --machine host_eg4qqky6xx --provider codex --permission-mode accept-edits \ --title "1302 target" --prompt "Reply only with ok." --json # -> thr_mfe9vetq34If the spawn returnsHTTP 503: Unable to load codex models to resolve the default, that is a transientprovider.list_modelstimeout on a loaded machine (the verifier hit it twice at load 120+); just retry the same command. - Exp. A: one send, count requests. Open the thread in the browser and install a fetch logger (browser-05-goto-thread.js; edit the ids at the top), then send one message from the shell and collect (browser-06-collect.js):
$ dev-browser --headless run 1302/repro/browser-05-goto-thread.js http://localhost:15464/projects/proj_qk56xcanay/threads/thr_mfe9vetq34 calls in the 3s after install (should be quiet): [] $ bb thread tell thr_mfe9vetq34 "Reply only with ok." Thread thr_mfe9vetq34 steered $ 1302/repro/1302-wait-idle.sh thr_mfe9vetq34 && dev-browser --headless run 1302/repro/browser-06-collect.js
0ms GET /api/v1/threads/thr_mfe9vetq34/timeline?afterSequence=18 -> 2333 bytes (34ms) 1ms GET /api/v1/threads/thr_mfe9vetq34/prompt-history? -> 122 bytes (54ms) 1ms GET /api/v1/threads?projectId=proj_qk56xcanay&parentThreadId=thr_mfe9vetq34&archived=false -> 2 bytes (96ms) 2ms GET /api/v1/threads?projectId=proj_qk56xcanay&sourceThreadId=thr_mfe9vetq34&originKind=fork&archived=false -> 2 bytes (96ms) 1ms GET /api/v1/threads/thr_mfe9vetq34/conversation-outline -> 486 bytes (97ms) 2ms GET /api/v1/sidebar-bootstrap -> 134865 bytes (96ms) 2ms GET /api/v1/threads/thr_mfe9vetq34 -> 597 bytes (224ms) 226ms GET /api/v1/threads/thr_mfe9vetq34/timeline?afterSequence=19 -> 986 bytes (41ms) 227ms GET /api/v1/threads/thr_mfe9vetq34/conversation-outline -> 486 bytes (41ms) 1284ms GET /api/v1/threads/thr_mfe9vetq34/timeline?afterSequence=21 -> 403 bytes (17ms) 1284ms GET /api/v1/threads/thr_mfe9vetq34/conversation-outline -> 486 bytes (17ms) 1362ms GET /api/v1/threads/thr_mfe9vetq34/timeline?afterSequence=23 -> 2840 bytes (22ms) 1363ms GET /api/v1/threads/thr_mfe9vetq34/conversation-outline -> 734 bytes (21ms) 1364ms GET /api/v1/sidebar-bootstrap -> 134861 bytes (78ms) 1365ms GET /api/v1/threads/thr_mfe9vetq34 -> 593 bytes (78ms) 1364ms GET /api/v1/threads?projectId=proj_qk56xcanay&parentThreadId=thr_mfe9vetq34&archived=false -> 2 bytes (79ms) 1364ms GET /api/v1/threads?projectId=proj_qk56xcanay&sourceThreadId=thr_mfe9vetq34&originKind=fork&archived=false -> 2 bytes (79ms) 1363ms GET /api/v1/environments/env_97azp4vnar/pull-request -> 77 bytes (222ms) 1561ms POST /api/v1/threads/thr_mfe9vetq34/read -> 593 bytes (68ms) TOTAL api calls: 19; sidebar-bootstrap calls: 2; sidebar-bootstrap bytes: 269726; all bytes: 280470Expected (issue's suggested behavior): the sidebar row forthr_mfe9vetq34is patched from the push event, no bootstrap refetch. Actual: two full 135 KB bootstrap downloads (offset 2 ms = turn start, 1364 ms = turn end), 19 requests total, 269,726 of 280,470 bytes are the bootstrap (send1-api-log.txt). Note the timeline uses?afterSequence=deltas; the sidebar has no equivalent. - Exp. B: concurrency multiplies it. Spawn two siblings (1302-spawn-siblings.sh; the two spawns alone caused 8 bootstrap refetches = 1,091,168 bytes), reset the logger, then tell all three at once (1302-tell-concurrent.sh):
$ 1302/repro/1302-tell-concurrent.sh thr_mfe9vetq34 thr_m4e7y5crhi thr_q93qsjsqnb && dev-browser --headless run 1302/repro/browser-06-collect.js 0ms GET /api/v1/threads/thr_mfe9vetq34/timeline?afterSequence=28 -> 3376 bytes (39ms) 0ms GET /api/v1/threads/thr_mfe9vetq34/conversation-outline -> 842 bytes (133ms) 0ms GET /api/v1/threads/thr_mfe9vetq34/prompt-history? -> 122 bytes (133ms) 3ms GET /api/v1/threads/thr_mfe9vetq34 -> 597 bytes (130ms) 28ms GET /api/v1/threads?projectId=proj_qk56xcanay&parentThreadId=thr_mfe9vetq34&archived=false -> 2 bytes (168ms) 29ms GET /api/v1/sidebar-bootstrap -> 136617 bytes (168ms) 28ms GET /api/v1/threads?projectId=proj_qk56xcanay&sourceThreadId=thr_mfe9vetq34&originKind=fork&archived=false -> 2 bytes (168ms) 218ms GET /api/v1/threads/thr_mfe9vetq34/timeline?afterSequence=29 -> 986 bytes (144ms) 218ms GET /api/v1/threads/thr_mfe9vetq34/conversation-outline -> 842 bytes (146ms) 1358ms GET /api/v1/threads/thr_mfe9vetq34/conversation-outline -> 1090 bytes (19ms) 1357ms GET /api/v1/threads/thr_mfe9vetq34/timeline?afterSequence=31 -> 1551 bytes (20ms) 1377ms GET /api/v1/threads/thr_mfe9vetq34/timeline?afterSequence=34 -> 3883 bytes (82ms) 1377ms GET /api/v1/threads/thr_mfe9vetq34/conversation-outline -> 1090 bytes (82ms) 1380ms GET /api/v1/sidebar-bootstrap -> 136613 bytes (80ms) 1380ms GET /api/v1/threads/thr_mfe9vetq34 -> 593 bytes (80ms) 1379ms GET /api/v1/threads?projectId=proj_qk56xcanay&parentThreadId=thr_mfe9vetq34&archived=false -> 2 bytes (81ms) 1379ms GET /api/v1/threads?projectId=proj_qk56xcanay&sourceThreadId=thr_mfe9vetq34&originKind=fork&archived=false -> 2 bytes (80ms) 1378ms GET /api/v1/environments/env_97azp4vnar/pull-request -> 77 bytes (112ms) 1489ms POST /api/v1/threads/thr_mfe9vetq34/read -> 593 bytes (147ms) 1624ms GET /api/v1/sidebar-bootstrap -> 136609 bytes (61ms) 1623ms GET /api/v1/threads?projectId=proj_qk56xcanay&parentThreadId=thr_mfe9vetq34&archived=false -> 2 bytes (62ms) 1623ms GET /api/v1/threads?projectId=proj_qk56xcanay&sourceThreadId=thr_mfe9vetq34&originKind=fork&archived=false -> 2 bytes (62ms) 1622ms GET /api/v1/environments/env_97azp4vnar/pull-request -> 77 bytes (92ms) 2546ms GET /api/v1/sidebar-bootstrap -> 136605 bytes (18ms) 2545ms GET /api/v1/threads?projectId=proj_qk56xcanay&parentThreadId=thr_mfe9vetq34&archived=false -> 2 bytes (18ms) 2545ms GET /api/v1/threads?projectId=proj_qk56xcanay&sourceThreadId=thr_mfe9vetq34&originKind=fork&archived=false -> 2 bytes (18ms) 2544ms GET /api/v1/environments/env_97azp4vnar/pull-request -> 77 bytes (114ms) TOTAL api calls: 27; sidebar-bootstrap calls: 4; sidebar-bootstrap bytes: 546444; all bytes: 562256Six status transitions in ~2.5 s produced 4 bootstrap downloads (546 KB, send3-concurrent-api-log.txt); the two "missing" ones were absorbed by react-query's in-flight dedupe. This number is timing dependent, not a constant. The independent verifier's re-run of the same script produced only 2 bootstrap downloads (273,212 B; verify/send3-concurrent-api-log.txt) because all three turns started within a few ms and all three ended within ~200 ms of each other, so each batch of three transitions was absorbed by one in-flight fetch. Spawning two siblings, whose transitions are spread over ~15 s, refetched 8× (1.09 MB) in my run and 6× (818,776 B; verify/spawn2-api-log.txt) in the verifier's. The rule is: everystatus-changedpush that arrives while no bootstrap fetch is in flight starts a new full download, so N concurrent turns cost between 2 and 2×N downloads. Sibling transitions also refetch the open thread's child/fork lists and PR state (project-scoped list invalidation from the same rule). - Unit-level repro of the invalidation rule (file: issue-1302-sidebar-bootstrap-refetch.test.ts; copy to
apps/app/src/hooks/). It passes on main, i.e. it documents current behavior: an activesidebarNavigationobserver withstaleTime: Infinityis refetched once perstatus-changedpush, including for a thread that is not in the sidebar payload at all. Run withcd apps/app && pnpm exec vitest run src/hooks/issue-1302-sidebar-bootstrap-refetch.test.ts.import { afterEach, describe, expect, it, vi } from "vitest"; import { QueryObserver } from "@tanstack/react-query"; import { createAppQueryClient } from "@/lib/query-client"; import { sidebarNavigationQueryKey } from "./queries/query-keys"; import { REALTIME_OWNED_STATIC_CACHE_QUERY_POLICY } from "./queries/query-policies"; import { createRealtimeCacheEffects } from "./realtime-cache-effects"; // Issue #1302: every `status-changed` realtime push for ANY thread (even one // that is not in the sidebar payload at all) invalidates the single // `sidebarNavigation` query, which refetches the whole // GET /api/v1/sidebar-bootstrap document (~1 KB per unarchived thread) instead // of patching the affected row. One turn = two status transitions = two full // refetches. This test documents that behavior on main; it passes today and // would start failing once status changes are patched into the cache. function sidebarFixture() { return { sections: [], projects: [ { id: "project-1", threads: [{ id: "thr_1", status: "idle" }], }, ], personalProject: { id: "proj_personal", threads: [] }, }; } describe("issue #1302: sidebar-bootstrap refetches wholesale on status-changed", () => { afterEach(() => { vi.useRealTimers(); }); it("refetches the whole bootstrap on every status transition, even for threads outside the sidebar", async () => { const queryClient = createAppQueryClient({ defaultOptions: { queries: { gcTime: Infinity, retry: false } }, showMutationErrorToasts: false, }); const effects = createRealtimeCacheEffects({ queryClient }); const fetchSidebarBootstrap = vi.fn(async () => sidebarFixture()); // Mount an active observer exactly like useSidebarNavigation() does // (staleTime: Infinity, so nothing but invalidation can refetch it). const observer = new QueryObserver(queryClient, { queryKey: sidebarNavigationQueryKey(), queryFn: fetchSidebarBootstrap, ...REALTIME_OWNED_STATIC_CACHE_QUERY_POLICY, }); const unsubscribe = observer.subscribe(() => {}); const settled = () => vi.waitFor(() => expect( queryClient.getQueryState(sidebarNavigationQueryKey())?.fetchStatus, ).toBe("idle"), ); await settled(); expect(fetchSidebarBootstrap).toHaveBeenCalledTimes(1); // Turn start: idle -> active for the one thread in the sidebar. effects.handleChanged({ type: "changed", entity: "thread", id: "thr_1", metadata: { projectId: "project-1" }, changes: ["status-changed"], }); await settled(); expect(fetchSidebarBootstrap).toHaveBeenCalledTimes(2); // Turn end: active -> idle. Another full refetch. effects.handleChanged({ type: "changed", entity: "thread", id: "thr_1", metadata: { projectId: "project-1" }, changes: ["status-changed"], }); await settled(); expect(fetchSidebarBootstrap).toHaveBeenCalledTimes(3); // A status change for a thread that is NOT in the cached sidebar payload // (hidden child, archived thread, other project) still refetches the world: // the invalidation is not scoped to the affected project or row. effects.handleChanged({ type: "changed", entity: "thread", id: "thr_not_in_sidebar", metadata: { projectId: "project-not-in-sidebar" }, changes: ["status-changed"], }); await settled(); // Nothing in the cache was patched in place; the row only changes because a // brand new document replaced it. expect(fetchSidebarBootstrap).toHaveBeenCalledTimes(4); unsubscribe(); effects.dispose(); }); });[1m[46m RUN [49m[22m [36mv4.1.1 [39m[90m/home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-24/apps/app[39m [2m Test Files [22m [1m[32m1 passed[39m[22m[90m (1)[39m [2m Tests [22m [1m[32m1 passed[39m[22m[90m (1)[39m [2m Start at [22m 07:57:27 [2m Duration [22m 1.64s[2m (transform 458ms, setup 20ms, import 1.32s, tests 211ms, environment 0ms)[22m


thr_mfe9vetq34 at the end of the run (after the spawn prompt, the exp. A send and the exp. B concurrent send, hence three "Reply only with ok." exchanges). The fetch logs above were captured on this page.Exp. C: parse cost is not the problem
browser-07-parse-cost.js parses the live payload 20× in the app page with and without CDP Emulation.setCPUThrottlingRate(6):
no throttle: {"bytes":136605,"medianParseMs":0.2,"maxParseMs":0.3}
6x cpu throttle: {"bytes":136605,"medianParseMs":1,"maxParseMs":1.5}
Exp. D: what a redundant refetch costs vs. patching one row
browser-09-refetch-vs-patch.js pulls the QueryClient out of the React fiber tree, then under 6× CPU throttle (a) changes one seeded row's title via PATCH /threads/:id, waits 1.5 s for the realtime-driven refetch to land, and times a further refetchQueries(['sidebarNavigation']) (fetch + parse + structural share; the data is now identical so no render follows), and (b) times a setQueryData patch that flips that row's status/runtime.displayStatus between active and idle, then waits two animation frames:
queryClient found: true
target row: {"projectId":"proj_38nn2cfvnd","threadId":"thr_z4yeti9xuz","title":"Rework error toasts to be actionable 57"}
(a) full refetch after one changed row, 6x cpu: [{"fetchParseShareMs":26.3,"plusRenderMs":35.4,"rootIdentityChanged":false},{"fetchParseShareMs":41.1,"plusRenderMs":60.9,"rootIdentityChanged":false},{"fetchParseShareMs":19.6,"plusRenderMs":23.8,"rootIdentityChanged":false},{"fetchParseShareMs":45.1,"plusRenderMs":57.8,"rootIdentityChanged":false}]
(b) setQueryData patch of one row, 6x cpu: [{"patchMs":1.2,"plusRenderMs":930.9},{"patchMs":1.5,"plusRenderMs":27},{"patchMs":0.9,"plusRenderMs":651.4},{"patchMs":1.3,"plusRenderMs":30.7}]
Reading: a redundant 136 KB refetch costs 20–45 ms of main thread at 6× throttle (≈4–8 ms unthrottled) and 11 KB gzip on the wire; the patch itself costs ≈1 ms. The 650–930 ms "plusRenderMs" on the idle→active flips is the sidebar reordering the newly active thread to the top of its project (compareStandardThreads) in the dev build; that render happens identically with today's refetch (structural sharing yields the same single changed row), so it is a separate cost, not one the payload causes. A CPU profile of a whole send at 6× throttle (profile-send-6x.txt, cpuprofile) shows the same picture: long tasks of 1,045/623/666/661 ms dominated by React dev-runtime render, SidebarWindowedItems, height-transition and projectThreadGroups, with fetch/JSON accounting for tens of ms.
Root cause
1. The bootstrap is the full ThreadListEntry for every visible thread of every project. buildSidebarBootstrapResponse (apps/server/src/routes/projects.ts#L251-L282) calls buildProjectsWithThreadsResponseFromRows (apps/server/src/routes/projects.ts#L209-L249), which returns toThreadListEntryResponses(…), the same shape as GET /threads, plus each project's sources and resolved defaultExecutionOptions. The contract is sidebarBootstrapResponseSchema (packages/server-contract/src/api/projects.ts#L539-L546) → projectWithThreadsResponseSchema → threadListEntrySchema (packages/domain/src/thread.ts#L378-L414). No field selection exists; one wire shape is shared by the sidebar, the thread list views, mentions and settings screens.
2. Realtime thread changes are dirty flags, not row updates. The server calls deps.hub.notifyThread(thread.id, ["status-changed"]) at turn start/end and on every runtime transition (e.g. apps/server/src/services/threads/thread-lifecycle.ts#L560, apps/server/src/services/threads/thread-lifecycle.ts#L1652, apps/server/src/services/threads/thread-send.ts#L620). The message metadata (packages/domain/src/change-kinds.ts#L171-L179) can carry only backgroundActivityChanged, eventTypes, hasPendingInteraction, projectId: nothing about the new status/runtime, so a client cannot patch a row from it.
3. The client's rule for status-changed is "invalidate every thread list, immediately". Registry entry (apps/app/src/hooks/cache-owners/realtime-cache-registry.ts#L326-L332):
"status-changed": {
flush: "immediate",
dirty: [
dirtyThreadListQueries, // List rows render status/runtime badges.
dirtyThreadDetailQueries, // Detail controls and banners depend on status.
],
},
dirtyThreadListQueries (apps/app/src/hooks/cache-owners/realtime-cache-registry.ts#L646-L658) returns getThreadListInvalidationQueryKeys (apps/app/src/hooks/cache-owners/cache-invalidation-groups.ts#L81-L96), which always includes sidebarNavigationQueryKey() (plus the project's thread lists and the search prefix). executeRealtimeDirtyHandlers then calls queryClient.invalidateQueries({ queryKey }) (apps/app/src/hooks/cache-owners/realtime-cache-registry.ts#L601-L612) with the default refetchType: "active", and the sidebar query is always active because AppLayout and ProjectList observe it (useSidebarNavigation, apps/app/src/hooks/queries/sidebar-navigation-query.ts#L36-L51, staleTime: Infinity, so realtime is the only refresh path). Because flush is immediate, handleChanged's case "thread" branch calls invalidationScheduler.flush() instead of schedule() (apps/app/src/hooks/realtime-cache-effects.ts#L256-L262, predicate shouldFlushThreadChangesImmediately at apps/app/src/hooks/cache-owners/realtime-cache-registry.ts#L614-L620), so the 50 ms debounce that batches other change kinds is bypassed, so start and end of a turn never coalesce; only react-query's in-flight dedupe limits the count under concurrency (exp. B).
4. The sidebar has exactly one cache entry, so the finest possible invalidation is "everything". sidebarNavigationQueryKey() is a single key with no project scope (apps/app/src/hooks/queries/query-keys.ts#L631-L633). Even the project-scoped list invalidations elsewhere in the same rule cannot avoid re-downloading the whole document.
Why the symptom follows. Every turn on any thread emits at least two status-changed pushes → two immediate invalidations of the one sidebar key → two full downloads of every visible thread of every project. Payload size scales with fleet size (1 KB × unarchived visible threads); refetch count scales with turn activity; the product is what the reporter (and the 1,071-thread production comment) see. The existing test "invalidates sidebar navigation for thread list changes" (apps/app/src/hooks/realtime-cache-effects.test.ts#L360-L386) pins this as intended behavior, so this is a design limitation rather than a regression.
Precedent inside the same registry. interactions-changed already does the incremental thing: the server includes hasPendingInteraction in metadata and the client runs patchThreadListPendingInteractionState → updateCachedThreadListPendingInteractionState (apps/app/src/hooks/cache-owners/realtime-cache-registry.ts#L859-L872, apps/app/src/hooks/cache-owners/query-cache.ts#L656-L669) over both the thread lists and the sidebar cache without any refetch. Status has no such path.
Side finding. pnpm seed:perf (the issue's own repro step) fails with FOREIGN KEY constraint failed if the dev server is running, because the server's destroyed-environment prune sweeps the 481 destroyed seed environments between the environment insert and the event insert (seed.log is the successful run after stopping the server; the failing run's stack pointed at packages/scripts/src/lib/seed-perf-fixture.ts:1005). Either the seed should run in one transaction / mark those environments ready, or the help text should say to stop the app first.
Proposed fix (first principles)
- Patch status from the push, do not refetch. Server side (product policy lives in the server; no daemon change, so no
HOST_DAEMON_PROTOCOL_VERSIONbump): extendthreadChangeMetadataSchemawith optionalstatus: threadStatusSchemaandruntime: threadRuntimeStateSchema, and have everynotifyThread(id, ["status-changed"], …)caller pass the post-transition values (they all have the thread row in hand; the lenient inbound schema means old tabs ignore the new fields). Client side: change thestatus-changedrule topatchThreadListStatusState(mirror ofpatchThreadListPendingInteractionState, applied throughapplyToCachedThreadListsAndSidebarNavigation) plusdirtyThreadDetailQueries; fall back to today'sdirtyThreadListQueriesonly when the metadata is absent (older server) or the thread is not present in the cache (it may need to appear). Keepflush: "immediate"for the patch. Risk: consumers that derive ordering from status (the active bucket incompareStandardThreads) already re-sort from cache data, so they keep working; the case to test is a status push arriving before thethread-createdrefetch has inserted the row (the fallback handles it). - Trim the wire shape. Introduce
sidebarThreadEntrySchemainpackages/domainas the subset the sidebar andAppLayout/mentions actually read (id, projectId, parentThreadId, environmentId, providerId, title, titleFallback, sectionId, status, runtime, activity, visibility, pinnedAt, pinSortKey, lastReadAt, latestAttentionAt, updatedAt, hasPendingInteraction, environmentHostId, environmentWorkspaceDisplayKind; environmentName/BranchName only if a row renders them) and makesidebarBootstrapResponseSchema.projects[].threadsuse it; dropsources[]anddefaultExecutionOptionsfrom the bootstrap and let the new-thread composer fetch defaults for the selected project (or keep them only forpersonalProject). Server↔app contract only; updatepackages/sdktypes and the app'sCachedThreadListsAndSidebarNavigationMapper(which currently assumes both caches holdThreadListEntry). Roughly halves bytes per row; measure with browser-06-collect.js. - Scope what still must refetch. For change kinds that legitimately need a re-read (created/deleted/archived/parent/order), key the sidebar per project (
["sidebarNavigation", projectId]plus a small project index) soprojectIdmetadata can target one project, or add?projectId=tosidebar-bootstrapand merge the response into the cache. This is the larger refactor; 1 and 2 give most of the win. - Optionally batch: give
status-changedthe 50 ms debounce (dropimmediate) once it is a cache patch instead of a network round trip, so a burst of sibling transitions renders once.
PR review
No open PRs are linked to this issue. Merged PR #1307 (sidebar virtualization, closes #1261) and closed PR #1481 (thread-open fan-out, #1303) both explicitly leave the bootstrap size/invalidation to this issue.
Related issues
- #1300: the
pnpm seed:perffixture this issue is measured against (fails if the server is running, see above). - #1303: opening a thread fires ~19 API requests, the same fan-out seen in exp. A; the child/fork-list and PR-state refetches on sibling status changes come from the same registry rule.
- #1261 / #1307: large active thread lists kept all sidebar rows mounted (fixed by virtualization; does not change the payload/refetch invariant).
- #1131, #1198: synchronous SQLite thread-list reads on the event loop; every extra bootstrap refetch is one more such read server-side (7–8 ms warm here, 100–200 ms under the concurrent load of exp. B).
Appendix
Commands run
# worktree /home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-24 at 16ceb3a54; app :15464, server :23464, daemon :31464
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
scripts/bb-dev-app current
pnpm seed:perf # FAILED: FOREIGN KEY constraint failed (server prune deleted destroyed envs mid-seed)
pnpm dev:stop && pnpm seed:perf -- --reset # ok: 12 projects, 1200 threads, 402298 events in 13.2s
scripts/bb-dev-app current
curl -s http://localhost:23464/api/v1/sidebar-bootstrap -o 1302/repro/bootstrap.json -w "http=%{http_code} bytes=%{size_download} time=%{time_total}s\n"
curl -s -X POST http://localhost:23464/api/v1/projects -H 'content-type: application/json' -d '{"name":"qa1302","source":{"type":"local_path","path":"/tmp/1302-qa","hostId":"host_eg4qqky6xx"}}'
bb thread spawn --project proj_qk56xcanay --machine host_eg4qqky6xx --provider codex --permission-mode accept-edits --title "1302 target" --prompt "Reply only with ok." --json # thr_mfe9vetq34
dev-browser --headless run 1302/repro/browser-01-open.js # boot; performance entries did not capture cross-origin API calls, hence the fetch logger
dev-browser --headless run 1302/repro/browser-05-goto-thread.js # open thread + install fetch logger
bb thread tell thr_mfe9vetq34 "Reply only with ok." ; 1302/repro/1302-wait-idle.sh thr_mfe9vetq34
dev-browser --headless run 1302/repro/browser-06-collect.js > 1302/repro/send1-api-log.txt
1302/repro/1302-spawn-siblings.sh proj_qk56xcanay host_eg4qqky6xx 2 # thr_m4e7y5crhi thr_q93qsjsqnb (8 bootstrap refetches, 1,091,168 B)
1302/repro/1302-tell-concurrent.sh thr_mfe9vetq34 thr_m4e7y5crhi thr_q93qsjsqnb
dev-browser --headless run 1302/repro/browser-06-collect.js > 1302/repro/send3-concurrent-api-log.txt
dev-browser --headless run 1302/repro/browser-07-parse-cost.js > 1302/repro/parse-cost.txt
dev-browser --headless --timeout 150 run 1302/repro/browser-08-profile-send.js > 1302/repro/profile-send-6x.txt # POST /threads/:id/send from the page, CPU profile at 6x
dev-browser --headless --timeout 150 run 1302/repro/browser-09-refetch-vs-patch.js > 1302/repro/refetch-vs-patch-6x.txt
cp 1302/repro/issue-1302-sidebar-bootstrap-refetch.test.ts apps/app/src/hooks/ && cd apps/app && pnpm exec vitest run src/hooks/issue-1302-sidebar-bootstrap-refetch.test.ts
pnpm dev:stop
Notes from the run
bb thread tellfrom inside another bb thread fails withHTTP 400: Sender thread is invalidbecause the CLI forwardsBB_THREAD_IDfrom the environment; the wrapper unsets it.bb thread spawnwithout--machinefailed withHTTP 404: Host not foundright after the seed reset (the seed removes host credentials and renames the host toseed-host); passing--machine host_eg4qqky6xxworks. Separately,bb thread spawn --provider codexcan fail transiently withHTTP 503: Unable to load codex models to resolve the default(dev.log:provider.list_models command_timeout) when the machine is heavily loaded; retry.- Server timing for the bootstrap under load (exp. B, from the page): 18–168 ms per response versus 7 ms via curl on an idle server.
- Browser
performance.getEntriesByType("resource")did not list the cross-origin API calls in this setup, so request accounting uses awindow.fetchwrapper installed after load (browser-05-goto-thread.js). The app's API client calls the globalfetchat call time, so the wrapper sees every request.
CPU profile of one send at 6× throttle (top of profile-send-6x.txt)
window 8236ms, sampled 9175ms (incl. idle)
--- self time by script url (top 25) ---
4968.4ms (native)
1385.5ms /node_modules/.vite/app/deps/react_jsx-dev-runtime.js
1056.4ms /node_modules/.vite/app/deps/react_jsx-runtime.js
843.5ms /node_modules/.vite/app/deps/react-dom_client.js
160.8ms /node_modules/.vite/app/deps/@tanstack_react-query.js
81.6ms /node_modules/.vite/app/deps/react.js
76.0ms /node_modules/.vite/app/deps/dist-CDr3_Uyy.js
57.2ms /src/components/sidebar/SidebarWindowedItems.tsx
56.6ms /src/components/ui/height-transition.tsx
36.9ms /node_modules/.vite/app/deps/@radix-ui_react-slot.js
26.9ms /@fs/home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-24/packages/shared-ui/src/components/ui/hooks/use-media-query.ts
23.7ms /src/hooks/useSenderThreadMetadataById.ts
21.9ms /node_modules/.vite/app/deps/chunk-C-Qwzh9l.js
21.2ms /src/hooks/usePromptDraftStorage.ts
18.4ms /src/views/thread-detail/ThreadDetailView.tsx
16.0ms /node_modules/.vite/app/deps/react-92emhbLV.js
13.3ms /node_modules/.vite/app/deps/zod.js
12.9ms /src/components/sidebar/ProjectRow.tsx
12.6ms /node_modules/.vite/app/deps/dist-H4yHTft2.js
11.8ms /node_modules/.vite/app/deps/react_compiler-runtime.js
11.2ms /src/components/sidebar/projectThreadGroups.ts
9.2ms /node_modules/.vite/app/deps/dist-C3Oyl4KQ.js
8.6ms /@fs/home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-24/packages/shared-ui/src/components/ui/dropdown-menu.tsx
8.6ms /node_modules/.vite/app/deps/@radix-ui_react-tooltip.js
8.5ms /node_modules/.vite/app/deps/tailwind-merge.js
--- self time by function (top 40) ---
2551.7ms (idle) (native):-1
1819.2ms (program) (native):-1
Verification
An independent verifier re-ran the minimal reproduction from a fresh worktree at 16ceb3a54 (own instance: app :12041, server :20041, daemon :28041) and a reviser then re-checked the findings from a third worktree (server :22777). What was confirmed and what changed:
- Confirmed as written: seed (12 projects / 1,200 threads / 402,298 events, server stopped), bootstrap size 133,602 B / 10,775 B gzip for 12 projects / 129 threads (report: 133,614 / 10,770), first row 1,002 B with the same 28 keys, exp. A = exactly 2
sidebar-bootstrapGETs per singlebb thread tell(134,860 B at turn start, 134,856 B at turn end; 269,716 of 281,654 bytes; verify/send1-api-log.txt), spawning two siblings = 6 refetches / 818,776 B, unit test 1 passed, every root-cause code excerpt matches the tree,git log 16ceb3a54..origin/maintouches none of the cited paths (not fixed on main), both screenshots are real 1400×900 PNGs. - Fixed (major): 1302-bb.sh previously only ran
scripts/bb-dev-app envwhenBB_SERVER_URLwas unset, so from a shell that inheritsBB_SERVER_URL(any bb agent thread) it silently targeted the user's real instance; the verifier's equivalent wrapper listed the real hosts (bee,Sawyer's MacBook Air) instead ofseed-host. The wrapper and 1302-wait-idle.sh now unset all inheritedBB_*, always evaluate the worktree'sbb-dev-app env, and echo the target URL. Re-tested withBB_SERVER_URL=http://127.0.0.1:38886exported:machine listwent to:22777and returned only that instance's host (wrapper-isolation-check.txt). - Corrected (minor): exp. B's "3 concurrent turns → 4 refetches" is now stated as a timing-dependent range (verifier measured 2 / 273 KB for the tells and 6 / 819 KB for the two spawns; verify/send3-concurrent-api-log.txt, verify/spawn2-api-log.txt). The TL;DR and claims table were updated to match.
- Corrected (minor): the "flush is immediate" permalink pointed at the
environment/hostbranch (L262–L270); it now points at thecase "thread"branchrealtime-cache-effects.ts#L256-L262and the predicaterealtime-cache-registry.ts#L614-L620, re-checked withsed -nat16ceb3a54. - Corrected (minor): the second screenshot's caption now says it was captured at the end of the run (three exchanges visible), and the repro notes mention the transient
HTTP 503: Unable to load codex modelsspawn failure and that a retry is enough.