Repository navigation
P 05 Streaming Batches
Context: After P-04, a result's rows appeared only once the whole result had arrived. DuckDB-wasm hands back a result as one Arrow table, so a query over a large file showed nothing until every row was computed and transferred, then rendered the first screen. On the reference laptop, a million-row Parquet file took about 1.3 s to show its first rows, most of it waiting for the full result.
Hypothesis: DuckDB-wasm can stream a result as record batches. If the grid shows each batch as it arrives, the first rows appear before the query finishes, and the time to show them is set by the first batch, not the whole result.
Change: #23, branch perf/23-stream-batches.
-
Streaming:
src/engine/engine.ts(streamQuery) sends a single read-only statement with DuckDB-wasm's streaming flag, and yields its batches as an async iterable. Results the engine can't stream, such as a write, several statements, or one with dictionary-encoded columns, run whole throughquery, as before. - Dictionary columns: DuckDB-wasm doesn't send dictionary batches on streamed results, so ENUM columns read as NULL. Results with dictionary-encoded fields fall back to a whole-result query.
-
Read-only guard:
src/engine/read-only.tsdecides whether a statement is a single read. A statement that writes, or aWITHthat writes, runs whole. - Connection held until the stream ends: running another query on the same connection closes a streaming result, so the engine queues other queries until the stream finishes or is abandoned.
- Errors: a streamed error comes back wrapped in another JSON object, so the engine unwraps it. Syntax-error messages keep their "near …" text.
-
Progressive rendering:
src/features/results/result-store.tsappends each batch to the result. The grid re-reads at most every 100 ms, so a fast stream doesn't re-render per batch. Column widths are fixed by the first batch, and the scroll position is kept as rows arrive. -
New measures:
blinkql:first-rows(query start to the first batch) andblinkql:first-rows-render(first batch to the frame after its rows are committed) insrc/lib/user-timing.ts.
Method:
-
Before:
943641c, which has the new first-rows measures and benchmark scenarios but not the streaming. After:7af567c, the change. Both were built from their own git worktrees, with the same dataset files, served byvite previewwith production headers. - Interleaved, one session: each scenario ran against both builds, one after the other, and the order alternated from one scenario to the next. Both builds ran through the same stretch of time and the same background load.
- Background load, stated plainly: this was run on the working laptop, not a quiet machine. Other apps were open and busy. CPU load, sampled every 30 s over the run (90 samples), averaged 44% (max 100%). Noise between runs is visible in the p95 columns; most differences below about 10% are not meaningful.
-
Scenarios: the four
first-rows-*scenarios (SELECT * FROM tover the million-row narrow Parquet and CSV files, the 100,000 × 50 wide Parquet file, and a filter that keeps the 5% of rows wherestatus = 'DOWN'), plus the M-dataset and scroll scenarios from P-04. The scroll and dataset scenarios useLIMIT, so they're in the results for completeness, not for the claim. - 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 headless.
All times are in milliseconds, given as median / p95.
firstRowsMs is from the query starting until the first rows reach the page. firstRowsRenderMs is from the first batch reaching the main thread until the frame after its rows are committed. The budget from #23 is 100 ms for the second.
| Scenario | Before | After | Change (median) | Render before | Render after |
|---|---|---|---|---|---|
| Every row, 1M narrow Parquet | 1,338 / 2,022 | 135 / 258 | −90% | 64.5 / 104 | 24.8 / 47 |
| Every row, 1M narrow CSV | 1,998 / 2,729 | 212 / 309 | −89% | 75.6 / 129 | 22.5 / 39 |
| Every row, 100k × 50 wide Parquet | 705 / 818 | 527 / 800 | −25% | 74.1 / 121 | 75.7 / 117 |
Rows where status = 'DOWN', 1M narrow Parquet |
7,376 / 8,456 | 2,605 / 2,980 | −65% | 48.2 / 70 | 44.2 / 54 |
Scroll, 1M narrow (LIMIT) |
2,284 / 2,603 | 1,412 / 1,853 | −38% | 72.6 / 89 | 36.3 / 42 |
Scroll, 100k wide (LIMIT) |
1,073 / 1,281 | 893 / 1,048 | −17% | 87.5 / 110 | 115.8 / 133 |
Total query time, queryMs, for the same scenarios: every row 1M narrow Parquet 1,272 → 1,207 ms (−5%); every row 1M narrow CSV 1,924 → 1,523 ms (−21%); every row 100k × 50 wide Parquet 632 → 667 ms (+6%); the filter 7,331 → 7,839 ms (+7%).
Time to finish showing the rows (renderMs, which covers all batches): every row 1M narrow Parquet 64.5 → 31.2 ms; every row 1M narrow CSV 75.6 → 30.6 ms; every row 100k × 50 wide Parquet 74.1 → 14.5 ms; the filter 48.2 → 14.0 ms.
These use a small LIMIT on the M datasets, so DuckDB computes the rows before the first batch is sent. First-rows timing barely moves here, as expected.
| Dataset | First rows before → after | First rows render before → after | Full render before → after |
|---|---|---|---|
| M narrow Parquet | 72 → 68 | 17.4 → 19.2 | 17.4 → 8.5 |
| M narrow CSV | 184 → 175 | 17.2 → 15.9 | 17.2 → 7.4 |
| M wide Parquet | 457 → 471 | 67.7 → 67.4 | 67.7 → 12.1 |
| M wide CSV | 569 → 533 | 70.6 → 61.3 | 70.6 → 13.3 |
- For a whole-file query, the first rows arrive 65–90% sooner on narrow data. The million-row narrow files show their first rows in 135–212 ms, down from 1.3–2.0 s, with the first batch rendered in 22–25 ms at the median. That meets the 100 ms render budget at the median and at p95 (39–47 ms).
-
The budget holds at the median in nine of ten scenarios. The exception is scroll on 100,000 wide rows, where first-batch render is 116 ms against 88 ms before. That scenario is
LIMIT-based, so streaming doesn't apply, and the extra time is the first batch's rows being rendered by a grid with 50 columns. - Wide data gains least on first rows. The 100,000 × 50 wide file's first rows arrive 25% sooner, but its first batch takes as long to render as before (76 ms), and the p95 is over 100 ms.
- The filter gains on first rows and not on total time. The 5% filter shows its first rows 65% sooner, but the full query takes 7% longer. Batch delivery adds a little cost that the first rows don't see.
- Full rendering is much cheaper. Once a result is complete, the time spent rendering its rows drops by 50–80% in every whole-file scenario, because the grid now renders batches as they come rather than all at once.
-
Scroll on 1M narrow
LIMITimproved on first rows (−38%). TheLIMITscenarios don't stream, so this improvement isn't explained by streaming. It's in the raw results, but this write-up doesn't claim a cause for it.
Trade-offs:
- Some queries take longer in total. Delivering batches costs a little on queries whose total time matters more than their first rows. In this run, total time rose 6–7% on two scenarios and fell 5–21% on the others.
-
LIMITandORDER BYget no first-row gain. DuckDB computes them in full before any row is sent. - Dictionary-encoded columns run whole. A result with ENUM columns isn't streamed, because DuckDB-wasm drops dictionary batches on streamed results. Those results show nothing until complete.
-
Only single read-only statements stream. Writes, multi-statement scripts and
WITHclauses that write run whole. - Other queries wait for a stream. The connection is held until the stream ends, so a second query waits for it. A user who runs a second query while the first is still streaming sees it start after the first completes.
- The grid re-reads at most every 100 ms. A fast stream updates the grid up to ten times a second rather than per batch. The rows shown can lag the stream by up to 100 ms.
Limitations:
- Chromium only, on the reference laptop. Firefox and WebKit were checked by the end-to-end tests, not benchmarked.
- WebKit on Windows streams slowly in test. A million-row result arrived in about 490 batches at about 15.6 ms each, roughly 7.6 s in total, which looks like a timer tick in Playwright's WebKit on Windows. Safari itself wasn't tested, so this may not reflect real Safari use.
- Background load. The run averaged 44% CPU with peaks at 100%, from other apps. Results were stable enough to show the first-rows gap, which is large, but small differences are noise.
- Headless frame times. Headless Chromium draws at 75 Hz, so a frame is 13.3 ms here, not 16.7 ms.
Evidence:
- Raw results, in the repository at
c47d955:2026-10-22T04-21-52Z-943641c.json(before) and2026-10-22T04-15-21Z-7af567c.json(after). Each file holds the ten scenarios of one build, merged from interleaved runs and flaggedinterleaved: true. - To reproduce: run
pnpm bench:data, thenpnpm bench --scenario first-rows-m-narrow-parquet --scenario first-rows-m-narrow-csv --scenario first-rows-s-wide-parquet --scenario first-rows-m-narrow-parquet-filterat each commit. For the scroll and dataset scenarios, add the names frombench/scenarios.
Conclusion: Adopt. Narrow results of any size show their first rows 65–90% sooner, and the first batch renders inside the 100 ms budget at the median in nine of ten scenarios. Wide data and LIMIT/ORDER BY queries gain less: wide first batches still take about 75 ms to render, and the scroll-on-100k-wide case misses the budget by 16 ms. The next step for wide results is column virtualization, the same idea as P-04 applied to columns.
⚡ Blinkql: queries in the blink of an eye · MIT licensed · Live app · Issues · Discussions
Project
Engineering
- Tech Stack
- Architecture
- Performance & Benchmarks
- Decision Records (ADRs)
- Privacy & Security
- Browser Support & Limitations
Design
Community