← reports

#1302 · sidebar-bootstrap ships 138KB and refetches wholesale on thread status changes

Perf Medium Effort: not set perf open on GitHub 2026-08-18 base 16ceb3a540f81c1189efaffb27a39b1d9443abf5 (main)

Verdict: REPRODUCED · root-cause confidence: high · linked open PRs: none

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

ClaimStatusEvidence
sidebar-bootstrap returns ~138 KB (11.7 KB gzip) for 131 unarchived threadsVerified133,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 themPartially verified1,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 transitionsVerifiedExp. 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 treePartially verifiedRefetches 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 #1261Not 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 CPUsPartially verifiedcurl: 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:perfBroken while the dev server is runningThe 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 affectedUnverifiable, consistentNothing 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 sendPartially verifiedObserved 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

Minimal reproduction

  1. 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)"
  2. 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
    1002
    Expected (issue): a sidebar-sized row (title, status, project, unread, pinned, provider, branch, attention time). Actual: the full ThreadListEntry (see bootstrap.json): 28 top-level keys including sourceThreadId, originPluginId, deletedAt, archivedAt, environmentBranchName, the five activity counters, plus each project's sources[] and defaultExecutionOptions.
  3. 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_mfe9vetq34
    If the spawn returns HTTP 503: Unable to load codex models to resolve the default, that is a transient provider.list_models timeout on a loaded machine (the verifier hit it twice at load 120+); just retry the same command.
  4. 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: 280470
    Expected (issue's suggested behavior): the sidebar row for thr_mfe9vetq34 is 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.
  5. 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: 562256
    Six 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: every status-changed push 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).
  6. 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 active sidebarNavigation observer with staleTime: Infinity is refetched once per status-changed push, including for a thread that is not in the sidebar payload at all. Run with cd 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();
      });
    });
    
     RUN  v4.1.1 /home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-24/apps/app
    
    
     Test Files  1 passed (1)
          Tests  1 passed (1)
       Start at  07:57:27
       Duration  1.64s (transform 458ms, setup 20ms, import 1.32s, tests 211ms, environment 0ms)
    
    
app with seeded sidebar
The app on the seeded database: 129 visible threads across 12 projects behind the (virtualized) sidebar. Every one of these rows is re-downloaded on any thread's status change.
thread view after send
Thread 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) → projectWithThreadsResponseSchemathreadListEntrySchema (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 patchThreadListPendingInteractionStateupdateCachedThreadListPendingInteractionState (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)

  1. Patch status from the push, do not refetch. Server side (product policy lives in the server; no daemon change, so no HOST_DAEMON_PROTOCOL_VERSION bump): extend threadChangeMetadataSchema with optional status: threadStatusSchema and runtime: threadRuntimeStateSchema, and have every notifyThread(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 the status-changed rule to patchThreadListStatusState (mirror of patchThreadListPendingInteractionState, applied through applyToCachedThreadListsAndSidebarNavigation) plus dirtyThreadDetailQueries; fall back to today's dirtyThreadListQueries only when the metadata is absent (older server) or the thread is not present in the cache (it may need to appear). Keep flush: "immediate" for the patch. Risk: consumers that derive ordering from status (the active bucket in compareStandardThreads) already re-sort from cache data, so they keep working; the case to test is a status push arriving before the thread-created refetch has inserted the row (the fallback handles it).
  2. Trim the wire shape. Introduce sidebarThreadEntrySchema in packages/domain as the subset the sidebar and AppLayout/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 make sidebarBootstrapResponseSchema.projects[].threads use it; drop sources[] and defaultExecutionOptions from the bootstrap and let the new-thread composer fetch defaults for the selected project (or keep them only for personalProject). Server↔app contract only; update packages/sdk types and the app's CachedThreadListsAndSidebarNavigationMapper (which currently assumes both caches hold ThreadListEntry). Roughly halves bytes per row; measure with browser-06-collect.js.
  3. 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) so projectId metadata can target one project, or add ?projectId= to sidebar-bootstrap and merge the response into the cache. This is the larger refactor; 1 and 2 give most of the win.
  4. Optionally batch: give status-changed the 50 ms debounce (drop immediate) 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

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

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: