Repository navigation
P 01 Baseline
Context: Milestone 1 deliberately ships a naive workbench: query results are copied out of Arrow into one object per row, kept in React state and rendered as a plain table with every row in the DOM. This page measures it end to end, so every later optimization has an honest "before."
Hypothesis: None. This run establishes the baseline.
Change: Instrumentation only, no optimization (#17). The app records blinkql:import, blinkql:query, blinkql:results-convert and blinkql:results-render User Timing measures, and pnpm bench gained one scenario per S and M dataset. Each scenario drives the UI like a user: it imports the file with the file picker, types SQL into the editor and presses Run. The scenarios and their metrics are described in bench/README.md.
Method:
-
Commit:
d73713b(clean working tree), served byvite previewwith the production headers. - Datasets: generator version 1, default seed. S (100,000 rows) and M (1,000,000 rows), narrow (8 columns) and wide (50 columns), CSV and Parquet.
- Iterations: 3 warm-up and 10 measured iterations per scenario and mode; median and p95 reported.
- Modes: the start-up scenarios run cold (fresh browser context) and warm (shared context). The dataset scenarios run warm only, so the engine download doesn't add noise to the ingest and query numbers.
- Environment: the reference laptop (below), on mains power, Chromium 156.0.8078.4 (Playwright's pinned build, headless).
| Machine | Lenovo ThinkPad (model 20LS) |
| CPU | Intel Core i5-8250U, 4 cores / 8 threads, 1.6 GHz base |
| Memory | 24 GB |
| OS | Windows 11 Pro (10.0.26300) |
| Browser | Chromium 156.0.8078.4, headless (full browser, not the headless shell) |
| Node | 26.12.0 |
All times are in milliseconds, given as median / p95.
| Metric | Cold | Warm |
|---|---|---|
| Time to app shell | 561 / 828 | 536 / 632 |
| Time to engine ready | 1,648 / 2,013 | 1,951 / 2,661 |
| Bytes transferred until engine ready | 34.6 MB | 34.3 MB |
The cold start meets its 4 s budget. The warm start misses its 1 s budget (finding 6).
| Dataset | Import | First query (count(*)) |
|---|---|---|
| S narrow CSV | 409 / 477 | 161 / 276 |
| S narrow Parquet | 303 / 318 | 12 / 13 |
| S wide CSV | 1,000 / 2,018 | 564 / 1,154 |
| S wide Parquet | 357 / 534 | 16 / 25 |
| M narrow CSV | 484 / 1,118 | 513 / 1,359 |
| M narrow Parquet | 338 / 591 | 16 / 40 |
| M wide CSV | 1,037 / 1,399 | 2,587 / 3,090 |
| M wide Parquet | 357 / 427 | 21 / 26 |
Each query returns at most 40 rows, so these times are the engine's work.
| Dataset | Scan | Filter | Group-by | Join | Window |
|---|---|---|---|---|---|
| S narrow CSV | 145 / 340 | 152 / 394 | 184 / 305 | 174 / 257 | 294 / 580 |
| S narrow Parquet | 41 / 42 | 121 / 128 | 68 / 71 | 55 / 56 | 144 / 162 |
| S wide CSV | 559 / 1,163 | 559 / 1,029 | 578 / 1,214 | 593 / 1,206 | 721 / 979 |
| S wide Parquet | 55 / 97 | 137 / 275 | 80 / 137 | 66 / 128 | 168 / 359 |
| M narrow CSV | 576 / 1,675 | 595 / 1,571 | 644 / 1,551 | 670 / 1,334 | 1,546 / 2,705 |
| M narrow Parquet | 163 / 308 | 1,096 / 1,402 | 248 / 277 | 192 / 229 | 1,011 / 1,226 |
| M wide CSV | 2,682 / 3,075 | 2,658 / 3,201 | 2,804 / 4,143 | 2,806 / 3,234 | 3,556 / 5,334 |
| M wide Parquet | 173 / 235 | 1,052 / 1,458 | 254 / 347 | 198 / 245 | 1,011 / 1,150 |
SELECT * … LIMIT 10000. The count is the same rows counted in the engine instead, so query − count is roughly the cost of transferring the rows to the main thread. Render runs from the result reaching the main thread until the frame after its rows are committed, so it includes the conversion.
| Dataset | Query | Count | Convert | Render |
|---|---|---|---|---|
| S narrow CSV | 118 / 233 | 118 / 176 | 40 / 74 | 2,694 / 3,314 |
| S narrow Parquet | 43 / 45 | 16 / 16 | 37 / 43 | 2,225 / 2,300 |
| S wide CSV | 456 / 816 | 474 / 676 | 129 / 280 | 14,525 / 18,857 |
| S wide Parquet | 374 / 817 | 23 / 42 | 162 / 273 | 13,246 / 18,107 |
| M narrow CSV | 162 / 321 | 157 / 334 | 37 / 73 | 2,494 / 4,038 |
| M narrow Parquet | 43 / 60 | 18 / 24 | 38 / 49 | 2,509 / 4,074 |
| M wide CSV | 464 / 755 | 438 / 920 | 133 / 173 | 12,259 / 14,690 |
| M wide Parquet | 348 / 488 | 29 / 44 | 149 / 201 | 12,733 / 16,925 |
From performance.measureUserAgentSpecificMemory(); the spread across iterations is under 1%.
| Dataset | Page total | Engine worker |
|---|---|---|
| Narrow (any size or format) | 88–96 MB | 34.5 MB (CSV), 42.5 MB (Parquet) |
| Wide (any size or format) | 317–324 MB | 34.5 MB (CSV), 42.5 MB (Parquet) |
-
Rendering is the bottleneck, by far. Showing 10,000 rows takes 2.2–2.7 s with 8 columns and 12–15 s with 50 columns, against a budget of 100 ms for first rows. The conversion to row objects is only 37–162 ms of that; the rest is React rendering and laying out every cell (500,000
<td>elements for a wide result). It costs the same for S and M, because the result size is fixed. This is the target of P-03 and P-04 (virtualization). - The DOM holds most of the memory. A wide 10,000-row result adds about 230 MB to the page compared with a narrow one, while the engine worker stays the same size. P-02 to P-04 should bring this down with the render cost.
- CSV files are parsed again on every query. Every query on M wide CSV costs about 2.7 s because the view reads the CSV each time, while the same queries on Parquet take 0.17–1 s. This is P-09 (convert CSV to Parquet on import).
- Transferring rows is cheap but scales with width. For Parquet, transferring 10,000 rows adds about 25 ms with 8 columns and 320–350 ms with 50 columns.
-
Filter and window queries on Parquet are slow relative to scans (about 1 s on M, against 163 ms for the scan). The filter reads a string column (
status) and a nullable float, and the window sorts within each machine. Worth profiling for P-08 (multithreaded bundle). - The warm start isn't warm. In warm mode the engine still downloads 34.3 MB and is ready no sooner than cold (1.95 s vs 1.65 s), so the WebAssembly and worker aren't being served from cache. The 1 s warm budget is missed. This is P-07 (lazy-load and cache the engine).
Trade-offs: The instrumentation itself is cheap: four performance.measure() calls per import or query. Rendering waits one frame before recording the render measure, and that wait is part of the measurement.
Limitations:
- Worker memory looks incomplete. It's the same for every size and shape, which suggests Chromium's measurement isn't counting all of DuckDB's WebAssembly memory. Treat the worker column as a lower bound until this is checked against a DevTools heap snapshot.
- No throttled profile yet. The 4× CPU slowdown profile isn't part of this run.
-
Chromium only. Firefox and WebKit can run the same scenarios (
--browser), but have no memory API. - Scroll frame times, long tasks and bundle size aren't measured here. Long-task detection is planned in M2 (#25), and bundle size comes with size-limit.
Evidence: Raw results: bench/results/2026-10-17T04-10-23Z-d73713b.json. To reproduce: pnpm bench:data, then pnpm bench.
Conclusion: Keep as the baseline. M2 starts where the numbers point: rendering (P-03, P-04) and holding results as Arrow (P-02).
⚡ 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