Skip to content

P 03 Result Store

Karthik P edited this page Oct 19, 2026 · 1 revision

P-03: Move results out of React state into a result store

Context: After P-02, cells were read straight from Arrow, but the result still passed through React. The results table subscribed to the whole query run and copied each result into its own state in an effect. So every query rendered the table three times:

  1. when the query started (the old result again, unchanged);
  2. when it finished (the old result once more);
  3. when the effect stored the new result.

ADR-004 says results live in a store outside React and components subscribe to small derived values.

Hypothesis: Holding the result in a store, and having the table subscribe only to what it shows, removes the extra renders and frees old results sooner.

Change: #21, PR #109.

  • Result store: src/features/results/result-store.ts holds the Arrow table's column readers. Components get only the result's shape (row count, column names and types) and read each cell with readCell() as they render it.
  • Query run: a finished query hands its table to the store and keeps only the row count. A failure or an engine restart clears the result.
  • Results table: it subscribes to the result and to one boolean (did the last run fail?), and it's memoized. So starting or finishing a query renders it only when the result itself changed, and the workbench re-rendering around it (say, when the engine's status changes) doesn't render it at all.
  • Not included: the "visible window" part of the store's API is left for P-04. Until the grid is virtualized, every row is visible.

Method:

  • Before: 32f0cb6, which has this experiment's benchmark harness and main's results code. After: 19173ee. Both had clean working trees and were served by vite preview with the production headers.
  • Same session, back to back, as for P-02, because the reference laptop drifts by about 10% between days.
  • Scenarios: the 8 S and M dataset scenarios (warm). Each now also runs a follow-up query: a one-row query (scan) run while the 10,000-row result is still on screen. That's where the extra renders cost the most.
  • React Profiler: S narrow and S wide Parquet were also run with React's profiling build (BLINKQL_REACT_PROFILING=1 pnpm bench). In that build, a <Profiler> around the table records every render, so the scenarios can count the renders and add up React's time.
  • Longest frame: the longest animation frame from pressing Run until the rows are shown, which is how long the page went without responding. It includes style, layout and paint. See Limitations for why this isn't the Long Tasks API.
  • Iterations: 3 warm-up and 10 measured iterations per scenario; median and p95 reported.
  • Environment: the reference laptop from P-01 (Intel Core i5-8250U, 24 GB, Windows 11 Pro 10.0.26300), on mains power. Chromium 156.0.8078.4 headless, Node 26.12.0.

All times are in milliseconds, given as median / p95.

React renders (profiling build)

Dataset Step Renders before Renders after React time before React time after
S narrow Parquet 10,000-row result 3 1 422 / 515 428 / 495
S narrow Parquet Follow-up query 3 1 259 / 297 0.7 / 0.9
S wide Parquet 10,000-row result 3 1 2,563 / 2,703 2,597 / 2,621
S wide Parquet Follow-up query 3 1 1,194 / 1,353 0.5 / 0.8

"React time" is the <Profiler>'s actualDuration, summed over the step's renders. It covers rendering components, not the browser's layout.

Follow-up query render (blinkql:results-render)

From the one-row result reaching the main thread until the frame after it is committed. It includes removing the 10,000 old rows from the page.

Dataset Before After Change (median)
S narrow CSV 436 / 479 211 / 247 −52%
S narrow Parquet 425 / 472 213 / 237 −50%
S wide CSV 2,137 / 2,221 1,159 / 1,346 −46%
S wide Parquet 2,276 / 3,039 1,076 / 1,265 −53%
M narrow CSV 431 / 489 199 / 226 −54%
M narrow Parquet 455 / 512 207 / 226 −55%
M wide CSV 2,203 / 3,253 1,108 / 1,293 −50%
M wide Parquet 2,146 / 2,261 1,098 / 1,225 −49%

Follow-up query, longest frame

Dataset Before After Change (median)
S narrow CSV 218 / 254 210 / 246 −4%
S narrow Parquet 215 / 247 213 / 236 −1%
S wide CSV 983 / 1,241 1,157 / 1,344 +18%
S wide Parquet 1,008 / 1,440 1,074 / 1,277 +7%
M narrow CSV 217 / 238 198 / 225 −9%
M narrow Parquet 222 / 240 207 / 225 −7%
M wide CSV 1,118 / 1,972 1,107 / 1,293 −1%
M wide Parquet 963 / 1,165 1,097 / 1,224 +14%

Memory after the follow-up query

From performance.measureUserAgentSpecificMemory(), median, once the 10,000 rows have been replaced by one.

Dataset Before After Change
S narrow CSV 59.0 MB 58.7 MB −0.3 MB
S narrow Parquet 68.1 MB 66.3 MB −1.7 MB
S wide CSV 138.8 MB 134.0 MB −4.8 MB
S wide Parquet 146.1 MB 141.5 MB −4.6 MB
M narrow CSV 60.7 MB 58.8 MB −1.9 MB
M narrow Parquet 67.2 MB 66.5 MB −0.7 MB
M wide CSV 142.3 MB 134.1 MB −8.1 MB
M wide Parquet 146.1 MB 141.5 MB −4.6 MB

With the 10,000 rows on screen, memory is unchanged: 87–95 MB narrow and 308–316 MB wide, before and after.

Showing the 10,000-row result

Dataset Render before Render after Change Longest frame before Longest frame after Change
S narrow CSV 2,697 / 2,983 2,843 / 3,100 +5% 2,597 / 2,890 2,751 / 3,021 +6%
S narrow Parquet 2,680 / 2,967 2,848 / 3,045 +6% 2,588 / 2,877 2,762 / 2,938 +7%
S wide CSV 13,198 / 13,706 13,898 / 14,509 +5% 12,884 / 13,385 13,595 / 14,144 +6%
S wide Parquet 13,280 / 13,473 13,440 / 13,599 +1% 12,975 / 13,170 13,128 / 13,312 +1%
M narrow CSV 2,705 / 2,795 2,734 / 2,806 +1% 2,613 / 2,701 2,648 / 2,720 +1%
M narrow Parquet 2,642 / 2,816 2,697 / 2,890 +2% 2,549 / 2,719 2,616 / 2,797 +3%
M wide CSV 13,249 / 13,476 13,602 / 14,176 +3% 12,947 / 13,162 13,310 / 13,875 +3%
M wide Parquet 13,391 / 13,517 13,503 / 13,845 +1% 13,041 / 13,194 13,179 / 13,546 +1%

For comparison, engine-only queries P-03 doesn't touch moved by −4 to +20% (scan) and −8 to +18% (groupBy) between the two runs. The largest moves were on S narrow, which ran slower overall in the "after" run.

Findings

  1. The extra renders are gone. The table renders once per query instead of three times. During the follow-up query, React now spends under 1 ms on it, down from 259 ms (narrow) and 1,194 ms (wide). That is the work P-03 set out to remove.
  2. The follow-up query shows its result in half the time: −46 to −55% on every dataset, for example 2.2 s → 1.1 s on wide results.
  3. The longest frame of the follow-up barely moved (−9 to +18%). The remaining frame removes the 10,000 old rows from the page and adds the new one. On a wide result that means 500,000 cells, about 1.1 s. Before P-03 that removal had its own frame, and the wasted renders came in other frames. Now everything is in that one frame. Only rendering fewer rows (P-04) makes it shorter.
  4. Old results are released sooner. After the follow-up query, the page uses 0.3–1.9 MB less on narrow results and 4.6–8.1 MB less on wide ones. Before, the query run held the Arrow table, and so did the table's state, through its column readers. React keeps a component's previous state until the component renders again, so the replaced table most likely stayed reachable for a while. Now nothing React holds references a table; only the result store does, and it keeps only the current one.
  5. Showing a large result costs the same. It was already rendered only once in the step that mattered. Its cost is about 0.4 s of React on a narrow result and 2.6 s on a wide one, plus the browser's style, layout and paint for every cell: 2.6 s narrow, 13 s wide in total. The +1 to +7% differences are within the noise of this pair of runs. P-04 targets this.

Trade-offs:

  • One more store with its own lifecycle, as ADR-004 expected. The query run and the result store have to be kept in step: the run clears the result on failure and on reset.
  • The table is rendered in one go. useSyncExternalStore updates are always urgent, so React can't split a large render into chunks the way it does for transitions. Before, the new result reached the table through an effect, and its render already ran in one task. So nothing is lost, but P-03 doesn't make a large render interruptible either. Rendering only the visible rows (P-04) makes that unnecessary.

Limitations:

  • Chromium only, reference laptop only, no throttled profile, as in P-01 and P-02.

  • The profiling build was run on two datasets only. Render counts don't depend on the data, and React's time scales with the number of cells. Profiling also makes React slower, so its times are only compared with each other.

  • Long Tasks gave a misleading result. The first runs measured the Long Tasks API, which suggested P-03 made the longest task 3–4× worse on wide results. A Chrome trace showed both builds doing the same work: about 3 s of React, then an 11 s frame of style, layout and paint. Chromium only reports a task as long if script ran in it. On main nothing ran in the 11 s frame, so it went uncounted. With P-03, the table's requestAnimationFrame callback runs in that frame, so it was counted. The harness now uses long animation frames, which include rendering. With them, the two builds measure the same (14.6 s and 13.9 s in that trace). Earlier runs that used long tasks weren't kept.

  • Measured before the review fixes. The measured commits predate four review fixes. Two of them matter here:

    • The table is now memoized. The benchmark never renders the table's parent, so this doesn't change the numbers.
    • The harness now ignores long frames that ended before Run was pressed. Chromium reports a frame some time after it ends, so a frame that ended just before Run, such as the memory measurement's garbage collection, could be counted.

    A later run of the final code with the fixed harness was compared with the "after" run on seven datasets. The follow-up query's longest frame differed by −10 to +5%, the same spread as its render times and the engine-only queries. So the bug didn't inflate the "after" numbers. The baseline for that later run had to be discarded: the laptop slowed to about half speed partway through it, with engine-only queries taking twice as long. So the "before" numbers here come from the earlier harness, and in principle a frame from before Run could be counted in them. That would only make the follow-up query's longest frame look longer before P-03 than it really was.

  • Worker memory is still a lower bound (P-01, limitation 1).

Evidence:

Conclusion: Adopt. P-03 removes two of the three renders per query and lets old results go. It halves the time to replace a large result. Its effect on main-thread blocking is limited by how expensive it is to put 10,000 rows in the DOM, and P-04 (virtualization) addresses that next. The store's readCell() already reads any row on demand, so P-04 can render just the visible ones.

Clone this wiki locally