<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Karthik Parameshwara]]></title><description><![CDATA[Articles by Karthik Parameshwara, a full-stack software engineer (backend-leaning) in Bengaluru, on building and measuring software. More at https://karthik.nz]]></description><link>https://karthikparameshwara.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 01 Nov 2026 01:31:43 GMT</lastBuildDate><atom:link href="https://karthikparameshwara.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[The SQL engine was fast. The table was the slow part.]]></title><description><![CDATA[Benchmarking Blinkql, an in-browser SQL workbench: the engine took 43 ms, but showing 10,000 rows took 2.2 s. What fixed it, and what didn't.]]></description><link>https://karthikparameshwara.hashnode.dev/the-sql-engine-was-fast-the-table-was-the-slow-part</link><guid isPermaLink="true">https://karthikparameshwara.hashnode.dev/the-sql-engine-was-fast-the-table-was-the-slow-part</guid><category><![CDATA[SQL]]></category><category><![CDATA[WebAssembly]]></category><category><![CDATA[performance]]></category><category><![CDATA[React]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[Karthik Parameshwara]]></dc:creator><pubDate>Fri, 30 Oct 2026 13:16:57 GMT</pubDate><content:encoded><![CDATA[<p>I built Blinkql, an open-source SQL workbench that runs in the browser. You open a CSV, Parquet, JSON or Excel file, write SQL and get results. The engine runs inside the tab, so the file is never uploaded: there's no account, no server and no backend at all.</p>
<p>The problem it answers is a common one. A lot of useful data lives in exports: a sales report, a support-ticket dump, an export of online-shop orders. When the built-in reports don't answer your question, the usual options aren't great. A spreadsheet stops at 1,048,576 rows. A cloud BI tool means uploading data that often isn't allowed to leave the company. A Python notebook works, but many of the people closest to the data can't use one.</p>
<p>The engine is DuckDB compiled to WebAssembly, running in a Web Worker, and it's fast. This post is about everything around it, because that's where the time went, and I only found that out by measuring first.</p>
<h2>Measure before optimising</h2>
<p>I deliberately shipped the first version naively: query results were copied into one JavaScript object per row, kept in React state and rendered as a plain HTML table with every row in the DOM. The point was to have an honest "before".</p>
<p>Then I built the benchmark harness, before touching performance:</p>
<ul>
<li>Deterministic datasets generated from a fixed seed, so every run uses identical data: 100,000 and 1,000,000 rows, narrow (8 columns) and wide (50 columns), in CSV and Parquet, plus 10 million rows in Parquet.</li>
<li>Scenarios that drive the UI like a user would: import the file with the file picker, type SQL into the editor, press Run.</li>
<li>3 warm-up runs, then 10 measured runs per scenario, reporting the median and the 95th percentile.</li>
<li>Raw results saved as JSON in the repository, with the commit they came from and the environment.</li>
<li>One reference machine: a laptop with an Intel Core i5-8250U (4 cores, 8 threads) and 24 GB of RAM, on Windows 11 Pro, using the Chromium build that Playwright pins.</li>
</ul>
<p>CI also runs a small benchmark smoke test on every pull request and fails it if a performance budget regresses. One budget matters most for this story: the first rows should be on screen within 100 ms of the engine returning a result.</p>
<h2>The baseline: 43 ms in the engine, 2.2 seconds on screen</h2>
<p>The first full run, which I called P-01, was clear. Take <code>SELECT * … LIMIT 10000</code> on the 100,000-row narrow Parquet file. The engine finished in a median of 43 ms. Showing those rows took 2,225 ms.</p>
<p>Across all the datasets, showing 10,000 rows took 2.2–2.7 s with 8 columns and 12–15 s with 50 columns. Converting the result into row objects was only 37–162 ms of that. The rest was React rendering and the browser laying out every cell: 500,000 <code>&lt;td&gt;</code> elements for a wide result. Memory pointed the same way. With a wide 10,000-row result on screen, the page used 317–324 MB, and most of that was the DOM.</p>
<p>So the engine wasn't the problem. The table was.</p>
<h2>Removing work, one experiment at a time</h2>
<p>Each change got a numbered write-up with a hypothesis, the method, before-and-after numbers and what got worse. Three of them did most of the work on this problem.</p>
<p><strong>Keep results as Arrow (P-02).</strong> DuckDB returns results as Apache Arrow, a columnar format. Instead of building row objects, the grid now reads each cell straight from its column as it renders. Preparing 10,000 rows went from 43–182 ms (measured against a fresh same-day run of the old code) to under 1 ms. Render time didn't change: the medians moved by 0–2%, within noise. I published that too. The conversion was a real block on the main thread, but it was never the expensive part.</p>
<p><strong>Move results out of React state (P-03).</strong> The table was rendering three times per query: when the query started, when it finished, and when an effect copied the new result into its state. Results now live in a small store outside React, and components subscribe only to what they show. The table renders once, and replacing one large result with another takes about half the time (−46% to −55%; for example 2.2 s to 1.1 s on wide results).</p>
<p>This experiment also caught a measurement mistake. The first runs used the Long Tasks API, which suggested the change made the longest task 3–4× worse on wide results. A Chrome trace showed both versions doing the same work. Chromium only reports a task as long if script ran in it. Before the change, the 11-second frame of style, layout and paint had no script in it, so it went uncounted. The harness now uses long animation frames, which include rendering. Taken at face value, the first number made a good change look bad.</p>
<p><strong>Render only what's in view (P-04).</strong> With TanStack Virtual, the grid keeps only the visible rows, plus 10 either side, in the page. That's about 30 rows whatever the size of the result, and each row reads its cells from the store as it scrolls in.</p>
<table>
<thead>
<tr>
<th>10,000-row result</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>Show, 8 columns</td>
<td>2.4–2.5 s</td>
<td>15–18 ms</td>
</tr>
<tr>
<td>Show, 50 columns</td>
<td>12.6–16.7 s</td>
<td>63–96 ms</td>
</tr>
<tr>
<td>Page memory, 50 columns</td>
<td>317–325 MB</td>
<td>57–65 MB</td>
</tr>
</tbody></table>
<p>A million narrow rows now show in 60 ms, and wheel scrolling runs at 75 fps at 10,000, 100,000 and 1,000,000 rows (headless Chromium draws at 75 Hz). Before this change, a million rows couldn't be shown at all.</p>
<p>It had costs, and the write-up lists them. Rows are one line high, so long values are truncated and shown in full in a tooltip. The browser's Ctrl+F only finds rows that are currently in the page. The <code>&lt;table&gt;</code> became an ARIA grid with its own keyboard navigation, so that screen readers still announce positions in the whole result. And it's more code to own.</p>
<p>Streaming came next (P-05): the grid renders Arrow record batches as they arrive instead of waiting for the full result. For whole-file queries on the million-row narrow files, the first rows now arrive in 135–212 ms instead of 1.3–2.0 s.</p>
<h2>The ideas the numbers didn't support</h2>
<p>Wide results still drop frames when you drag the scrollbar: about 14 fps at 10,000 rows. The obvious fix is to draw the grid on a canvas and skip the per-cell layout and paint, so I tried it (P-11).</p>
<p>Drag scrolling didn't change. Dragged frames took about 66.7 ms at the median with both renderers. The canvas removed the last one or two slow frames from wheel scrolling and rendered rows 10–20% faster, but it added about 1.3 MB of memory, accessibility gaps and about 600 lines of code. Whatever limits a dragged frame on wide results, painting cells isn't it. I kept the DOM grid and left the canvas version on a branch, unmerged.</p>
<p>A multithreaded build of the engine (P-08) went the same way: faster on some aggregations, but it ran into extension, file-access and crash issues. Of the 13 experiments, those two are the ones I didn't adopt.</p>
<h2>What I took from it</h2>
<ul>
<li><strong>Measure the whole path, not just the part you assume is slow.</strong> It's easy to assume the database engine is the bottleneck. Here it was the DOM.</li>
<li><strong>Publish the changes that don't move the headline number.</strong> P-02 barely changed render time, but it made P-03 and P-04 possible.</li>
<li><strong>Check the measuring tool too.</strong> A metric that silently skips some of the work can make a good change look bad.</li>
<li><strong>Write down what got worse.</strong> Every fix here traded something away: text wrapping, the browser's own search, code size. Listing it keeps the decision honest.</li>
</ul>
<p>Blinkql is MIT-licensed. You can try it with your own files at <a href="https://blinkql.karthik.nz/app/">blinkql.karthik.nz</a>, and nothing is uploaded. The code is at <a href="https://github.com/Karthik-beta/Blinkql">github.com/Karthik-beta/Blinkql</a>, and every write-up, with its raw results, is linked from the <a href="https://github.com/Karthik-beta/Blinkql/wiki/Performance-and-Benchmarks">Performance and Benchmarks</a> wiki page. If you run the benchmarks on your own hardware (<code>pnpm bench:data</code>, then <code>pnpm bench</code>), results from other machines are welcome.</p>
<hr />
<p>I'm <a href="https://karthik.nz">Karthik Parameshwara</a>, a software engineer in Bengaluru. More of my work is at <a href="https://karthik.nz">karthik.nz</a>.</p>
]]></content:encoded></item></channel></rss>