Robert O'Neill ·
A 140,000-row spreadsheet, ready to type in half the time of Google Sheets
VisiGrid has a native desktop app and a web app. On Monday the web app opened small sheets about as fast as Google Sheets and large ones clearly slower: 4.2 s before you could type into a 140,000-row sheet, against about 1.8 s. That was embarrassing enough to spend two days on. By Tuesday afternoon it was faster than Sheets on every measure we take.
The short version: on a 140,000-row sheet, VisiGrid is now ready to type into in 1.09 s, against Google Sheets’ 2.16 s. The screen finishes settling in 0.67 s against 7.2 s. Small and medium sheets are faster too.
One recording of each, same laptop and Wi-Fi, one after the other, timers from navigation. It’s a single cold open and it shows 1.38 s against 3.46 s. The five-round medians further down come from the harness and are not the same numbers.
How we measure
We measure what a person sees, not what the code reports. A harness drives Chrome over the DevTools protocol, records the screen at 60 frames a second, and reads every timing off the frames themselves, the same way for every product:
- Cells shown: the first frame from which the cell area keeps at least 90% of its final text pixels. The sheet is readable from then on.
- Ready to type: on a separate load, how long until a key typed into an empty cell actually appears on screen. The harness types a key every 50 ms, checks the frame, and confirms with Escape and a second key.
- Screen settled: the first frame after which the whole screen stays within 0.2% of its final state, toolbars and layout shifts included.
- Arrow key: key press to the first changed frame.
A word on that last one, because the precision looks wrong. Frames arrive about every 16.7 ms, and each carries Chrome’s frame-swap timestamp, so the number is a real interval rather than a frame count. But the resolution is still one frame: a 9 ms median means the selection moved in the very next frame, with the key landing on average mid-interval, and 17 ms means it usually took the frame after that. Read the row as one frame against two, not as 8 ms of daylight. The interval also includes the DevTools round trip that delivers the keypress, which inflates every product equally.
“Cold” clears the browser cache first. “Warm” is a reload. Each figure is the median of 5 rounds. A run whose network route took over 400 ms is dropped, because at that point we’re measuring the Wi-Fi. All runs were done on one laptop on local Wi-Fi, the same network and machine before and after.
The workbooks are generated deterministically and imported into each product:
| Workbook | Size |
|---|---|
| small | 20 rows with formulas (mostly the product’s own startup) |
| medium | 12,000 rows, ~60,000 cells, a formula column and totals |
| large | 140,000 rows × 4 columns of plain values (560,000 cells) |
Where the time went
Opening a sheet means downloading its stored snapshot and loading it into the engine: the same Rust engine the desktop app uses, compiled to WebAssembly in a Web Worker. The page also joins a collaboration session on the server, which runs its own copy. You can type once the browser’s copy is loaded and the session has welcomed you.
A large sheet is stored in bands: a small manifest, plus one compressed, content-addressed file per 16,384 rows. The 140,000-row sheet is a manifest and 9 bands. Before this week, opening it looked like this:
- The server shows a first screen (the top rows, rendered on the server) almost at once.
- The grid goes blank.
- The bands download, one after another behind the engine, and load into it.
- About 4 seconds in, you can type.
Sheets was ready in under 2.
The fixes
1. Compress the snapshots
Stored snapshots were plain JSON. The medium workbook’s was 2.06 MB. Gzipped, it’s 319 KB. We now compress every snapshot over 1 KB when compression makes it smaller, and decode it on every read path. That includes the browser, where we use DecompressionStream, because R2’s signed URLs don’t always send Content-Encoding. Medium went from 2.2–2.5 s to type to 1.2–1.4 s.
A detail that bit us: tiny documents get bigger when gzipped. Six billing tests caught it, because quotas count stored bytes.
2. Fetch the bands in parallel on the server
When a collaboration room starts, the server’s engine loads every band. It fetched them one at a time. Now it keeps six fetches in flight and applies them in order as they land. Large room start went from 3.1–4.1 s to 1.84 s.
3. Don’t blank the first screen
A filmstrip showed the large sheet’s cells at 0.84 s, a blank grid at 1.17 s, and cells again at 2.04 s. When the engine opened, the page swapped the server’s first screen for the engine’s view, which is empty until the first band arrives. Now the first screen stays until band 1 (the top rows) is loaded. Warm cells-shown on the large sheet went from 1.03 s to 0.30 s.
4. Build the server’s engine for speed, not size
Our Rust release profile is opt-level = "z", which is the right choice for a WebAssembly download. The server built its engine from the same profile. Rebuilt at opt-level = 3, applying the large sheet’s 560,000 cells went from ~2.9 s to ~1.3 s on the same machine. In production, the band-loading step went from 1.29 s to 0.72 s.
That’s a one-line change in a Dockerfile.
5. Build only the hot WebAssembly crates for speed
The browser applies the same bands with the same engine, size-optimized. We measured four builds:
| Collab WASM | Brotli download | Apply 9 bands |
|---|---|---|
| all size-optimized | 1.39 MB | 1.44–1.47 s |
| io + serde_json + inflate at O3 | 1.56 MB | 1.04–1.15 s |
| + engine at O3 | 1.74 MB | 0.80–0.96 s |
| everything at O3 | 1.89 MB | 0.88–1.08 s |
We shipped the third row. Cargo can set opt-level per package, so the four crates that do the band work are fast and everything else stays small. Building everything at O3 is no faster and 150 KB bigger.
6. Download from the page, not the worker
We expected the worker to start downloading bands the moment it had the manifest. A timeline showed otherwise: the page handed the worker the manifest at 0.32 s, but the worker didn’t handle that message until 0.94 s, just after the WebAssembly finished compiling. The worker’s thread was busy for that whole window. Any “start early” logic inside the worker was waiting behind the compile.
So the page now signs and downloads the bands as soon as it has the manifest, and the worker asks the page for each band when it’s ready to apply it. We also stopped applying a band from blocking the next download. Band signing moved from ~2 s to ~0.6 s.
7. Let people type while it loads
By now the remaining wait was the engine applying the bands, and the engine is right to refuse edits until every band is in. A band writes into empty cells, so an earlier edit to its rows would be overwritten by older data.
So we changed what the page allows instead. An editor can type from the first screen. Single-cell edits are held, shown immediately through an overlay on the grid, and sent in order the moment the session is live. Paste, fill, sort, structural changes and undo wait, with a short notice. If the sheet never goes live (read-only, refused), held edits are kept on the device and offered back, never silently dropped.
On the large sheet, the grid accepts typing at 0.30–0.37 s, the same frame as the first screen, about two seconds before the session is live.
8. Two bugs the benchmark found
Once typing during load was allowed, the benchmark’s typing probe started warning: “the typing cell is not empty after Escape.” The probe types a key and presses Escape, which should never leave anything behind. Nothing had been saved (the workbook’s revision was unchanged), so we chased it:
- A dead editor. When the engine took over from the first screen, the grid reset its editing state but left the editor box on screen. A draft typed at that moment could be neither cancelled nor saved. Nobody could reach it until the page started accepting keys that early. The new tests fail on the old code.
- A sideways jump. The warning came back. This time the probe saved its before and after frames, which showed the whole grid shifted 8 px. The row-number gutter is sized by the row count. The first screen knows the sheet has 140,001 rows, but the engine counts only the rows loaded so far until the last band lands. So the gutter narrowed at the takeover and widened at the end, and the grid moved twice on every large-sheet open. It had been there all along. Fixing it also cut warm “screen settled” on the large sheet from about 2 s to 0.45 s.
9. React’s 300 ms
The final full run showed cold opens of small sheets had got slower: cells at 0.80 s instead of 0.43 s. Blocking the WebAssembly or the fonts changed nothing. A performance trace showed the grid’s code arriving at 0.33 s, then the main thread idle until a timer at 0.70 s, then the sheet.
The grid page is lazily loaded inside a React <Suspense> boundary. On a cold load, sign-in finishes before the grid’s code arrives, so the route suspends and shows its loading fallback. React 19 deliberately holds revealed content until ~300 ms after a fallback appeared, so that several loading boundaries reveal together. For us that meant up to 300 ms of doing nothing on every cold open. The route now shows its own loading text and renders the grid the moment its code resolves. Cold first screen dropped back to 0.27–0.39 s.
What didn’t work
- Guessing at the hot loop. Our first idea for the engine’s band loading was to skip bookkeeping when writing into an empty cell. It made no measurable difference. A profiler (and then the optimization level) found the real cost.
- A gap that wasn’t there. We once “found” a 0.45 s gap between the welcome and the live session. It was a Playwright
waitForSelectorartifact: a MutationObserver showed the two within 1 ms. - Trusting the measuring tool. Late on, the typing probe reported the large sheet getting slower (2.3 s instead of 1.1 s) right after a fix that made the first screen appear sooner. The page was accepting keys from 0.4 s. The probe’s first keypress now coincided with the first screen appearing, it read the page appearing as the key, and then waited 1.5 s for a blank box that could never come back. The probe now types only into a screen that has stopped changing, the same rule for every product.
- Absolute numbers on a busy laptop. Several runs were spoiled by another build on the same machine, or by running on battery. The harness now refuses to start on battery or with the window hidden. We only trust comparisons made back to back.
Results
(Medians, 5 rounds, local Wi-Fi, the same laptop and network as the “before” run from the day before.)
VisiGrid vs Google Sheets, 2026-10-13 (run open-bench/results/2026-10-13T17-03-43-608Z). Lower is better.
| Small: VisiGrid | Small: Sheets | Medium: VisiGrid | Medium: Sheets | Large: VisiGrid | Large: Sheets | |
|---|---|---|---|---|---|---|
| Cells shown, cold | 0.65 s | 1.00 s | 0.63 s | 0.87 s | 0.61 s | 0.85 s |
| Cells shown, warm | 0.41 s | 0.45 s | 0.36 s | 0.46 s | 0.36 s | 0.42 s |
| Ready to type | 0.66 s | 1.70 s | 1.51 s | 1.73 s | 1.09 s | 2.16 s |
| Screen settled, cold | 0.69 s | 6.68 s | 0.73 s | 7.03 s | 0.67 s | 7.21 s |
| Screen settled, warm | 0.47 s | 4.32 s | 0.47 s | 4.58 s | 0.44 s | 3.94 s |
| Arrow key (median / p90) | 9 / 10 ms | 16 / 25 ms | 9 / 10 ms | 17 / 25 ms | 9 / 10 ms | 17 / 25 ms |
The large sheet, before and after (VisiGrid, same laptop and network; “before” is Monday’s run 2026-10-12T16-05-26-176Z):
| Large sheet (140,000 rows) | Monday | Tuesday |
|---|---|---|
| Ready to type | 4.17 s | 1.09 s |
| Cells shown, cold | 2.16 s | 0.61 s |
| Cells shown, warm | 1.39 s | 0.36 s |
| Screen settled, cold | 3.89 s | 0.67 s |
| Screen settled, warm | 2.56 s | 0.44 s |
Notes on fairness: Sheets’ network route was slower than ours in this run (it’s served from Google, ours through a nearby Cloudflare edge), and runs whose route took over 400 ms were dropped for both. “Screen settled” punishes Sheets for restyling its toolbars for several seconds after the cells are readable, so “cells shown” and “ready to type” are the fairer headline numbers. Cold small and medium cells-shown were ~0.4 s on Monday’s quieter network; the comparison that holds is the same-run one above.
What’s next
- A closer server. The origin sits about 75 ms from where these runs were made. Moving it nearer helps every cold load.
- Faster band loading in the engine itself. Each band is parsed twice, once to validate and once to write.