It's the week before vizcrush goes public, and I have two files open side by side. On the left, the launch copy: the JS core beats the most popular npm downsampling package by 32×, "and WASM adds another 5-10x on top." On the right, the repo's own benchmark control run: wasm/js ≈ 1.00×. One million points, same algorithm, same machine. Parity.
I go looking for the measurements behind the claim. Half of it holds up, with a caveat: the 32× JS comparison has a result file, one committed run that measured 1.72ms against 55.52ms at a million points. It exercises the shipped lttbSync, but the input is unseeded and the file records neither platform nor Node version, so it is one run, not a controlled suite. The claimed additional 5-10× from WASM has nothing behind it, and the repo's own control run contradicts it. That afternoon set the shape of the whole launch: before anything shipped, every performance claim would either get a measurement behind it or get deleted. Three beliefs didn't survive. Each one got a public retraction, written up as an ADR in the repo.
| Belief | Measurement | Shipping decision |
|---|---|---|
| SIMD provides the speedup | No material difference | Remove SIMD performance claims |
| WASM is consistently faster | Engine- and version-dependent; the public API is often near parity | Keep both the WASM and JS paths |
| WebGPU must be fastest | Roughly 15× slower end-to-end on tested hardware | Opt-in only |
vizcrush is a set of data primitives for browser visualization (downsampling, binning, spatial indexing, streaming sketches), written in Rust, compiled to WebAssembly, with a pure-JS core behind the same API as a fallback and explicitly selectable backend. It went open source this week: the repo and the book are public, and all 11 packages are live on npm.
In one line: vizcrush reduces massive browser datasets into bounded, renderer-ready data for D3, Three.js, deck.gl, Canvas, WebGL, and WebGPU.
npm install @vizcrush/downsample
import { lttb } from "@vizcrush/downsample";
// Preserve the visual shape of 1M points in a 1K-point result.
const result = await lttb(timestamps, values, 1_000);
This is a launch story about turning benchmark results into product policy: claims, documentation, and WebGPU policy follow the measurements, while WASM dispatch stays availability-based pending further investigation.
One scope note before the data. Every result here is workload-specific: LTTB (Largest-Triangle-Three-Buckets, the downsampling algorithm that picks, per bucket, the point that best preserves the visual shape of the line) is downsampling, the stats kernel is a reduction, and bin2d is histogramming. Which backend wins is algorithm- and engine-dependent, so none of what follows is a library-wide WASM-versus-JS verdict. It is three specific workloads measured on specific engines, with the claims and documentation following each measurement.
Belief one: SIMD is doing the work
The WASM build had carried -C target-feature=+simd128 from early on, and the docs treated the flag as the fast path: a doc comment in stats.rs even described a "SIMD pre-scan" over the data. Then that control run showed the public WASM path at parity with the JS core, and the obvious question landed: is SIMD engaged at all?
The test is simple: build the module twice, with and without the flag, and hash the binaries. The downsample module built with +simd128 is byte-identical to the scalar build: same SHA-256. The flag produced zero different code for LTTB. The aggregate module differs by a few hundred bytes, so the flag changed code generation somewhere. And then the timings say it doesn't matter:
| Algorithm | Size | wasm-simd | wasm-scalar | js core |
|---|---|---|---|---|
| lttb | 100K | 157µs | 160µs | 250µs |
| lttb | 1M | 1.85ms | 1.81ms | 1.82ms |
| lttb | 10M | 16.79ms | 16.61ms | 16.65ms |
| compute_stats | 1M | 4.06ms | 4.03ms | n/a |
| compute_stats | 10M | 41.15ms | 40.72ms | n/a |
Takeaway: for these measured kernels, enabling the flag produced no material runtime change. SIMD-on and SIMD-off are the same speed at every size, even for the stats loop I would have sworn was vectorizable. Method, since numbers without one are how this mess started: raw WASM exports called directly (so marshalling doesn't blur the comparison), Node v24.14.1 on darwin/arm64, release profile (opt-level=3, LTO, a single codegen unit), one machine: treat the absolutes as approximate and read the ratios. And the gaps, stated instead of papered over: the Node result files retain median, p95, and minimum per case, but percentile bands like p10/p90, allocation counts, and machine power-state control are not captured yet, in this harness or the others; that's noted as future benchmark work.
The likely reason is mundane. These hot loops branch on every element: LTTB's core is an argmax (if area > max_area on each candidate point), and the stats kernel skips non-finite values and tracks min/max, all branches. Branch-heavy loops like these did not autovectorize in these builds, and there are no hand-written SIMD intrinsics in the Rust to force the issue. The "SIMD pre-scan" comment described code that does not exist; it's gone.
THE WASM-OPT SUBPLOT
The same investigation found the shipped binaries had never been run through
wasm-opt: the build script only invoked it if binaryen happened to be installed, and it wasn't, so the step silently skipped for the project's entire life. Fixing the script (it now fails loudly) bought a ~10-12% smaller binary and no runtime gain (LTTB measured 1.05-1.10×, marginally slower; stats 1.00×).
The decision in ADR 0002 is deliberately boring: keep the flag, since it's harmless and occasionally shrinks a binary, but don't hand-write intrinsics, because at the input sizes that matter these algorithms are memory-bandwidth bound, and vectorizing the compute would optimize something that isn't the bottleneck. And delete every SIMD speedup claim from the docs.
Belief two: WASM is fast everywhere
Killing the SIMD claim left a follow-up question: why keep WASM at all? ADR 0002's answer was cross-engine consistency: JS engine performance varies wildly, WASM is predictable, so WASM is the safe default. It sounded right. It was also untested, because every measurement so far had run in Node. Which is to say: in V8, one engine.
So the follow-up drove real browsers: playwright-core driving locally cached browser binaries, specifically Chromium (V8), Firefox Nightly rv:144 (SpiderMonkey), and WebKit 2227 (JavaScriptCore), running the raw wasm-bindgen LTTB export against the JS core, served over local HTTP and timed in-page. One measurement trap worth passing on: Firefox and WebKit coarsen performance.now() to about a millisecond as a Spectre mitigation, so per-call timing returns zeros and round milliseconds. The workaround is batch timing: run N calls as one block, take the minimum over several reps, divide by N. Anything not listed in ADR 0003's method section, headless versus headed mode per engine, machine power conditions, was not controlled for.
| Engine | Size | wasm | js core | wasm/js |
|---|---|---|---|---|
| Chromium (V8) | 100K | 150µs | 643µs | 0.23× |
| Chromium (V8) | 1M | 1.52ms | 6.03ms | 0.25× |
| Firefox (SpiderMonkey) | 100K | 1.97ms | 268µs | 7.37× |
| Firefox (SpiderMonkey) | 1M | 17.2ms | 2.13ms | 8.09× |
| WebKit (JavaScriptCore) | 100K | 188µs | 142µs | 1.32× |
| WebKit (JavaScriptCore) | 1M | 1.98ms | 1.38ms | 1.44× |
Takeaway: for the raw kernel, WASM is a decisive win in Chromium, about 4× faster than the JS core (a result that turned out to be version-scoped; the sequel is below); it's roughly 8× slower in Firefox at a million points and modestly slower in WebKit, cold start (first call, instantiation and JIT warmup included) is slower than JS in every engine, and the JS core is the more consistent of the two (1.4-6ms across engines at 1M points, against WASM's 1.5-17ms). Method: every cell is a batch-timed minimum, the minimum over several reps of an N-call block divided by N, per the ADR's method section; that minimum is the recorded data, so within-engine run-to-run spread was not kept.
BEFORE QUOTING THESE NUMBERS
Within-engine wasm/js ratios are the trustworthy output; the absolutes are runtime-dependent (headless Chromium's JS core measured about 3× slower than Node's; a later version sweep traced that gap to the V8 build rather than the headless configuration, and Chromium 149 erased it, so never compare absolutes across runtimes). And Firefox's 17.2ms WASM is flagged, not root-caused: per-call marshalling in SpiderMonkey is a hypothesis, nothing more, and the repo tracks it as an open investigation (ADR 0003 lists root-causing it as a precondition for any per-engine dispatch). The qualitative conclusion doesn't depend on it: WebKit also shows WASM slower, cleanly.
ADR 0003 keeps WASM anyway, for an honest reason instead of a wrong one. A ~4× Chromium/V8 win was worth preserving rather than regressing. The consistency rationale is retracted in writing, and the README carries the framing the data supports: engine- and version-dependent, comparable to or slower in Firefox and Safari, cold first calls slower everywhere.
That framing earned its keep faster than expected. Three months after the campaign, the 4× stopped reproducing: current Chromium ran the JS core at about 1.8ms instead of 6ms, putting wasm/js near 0.9. A five-build version sweep (harness, machine, seed, and statistic held fixed while only the Chromium binary varied; run twice, with both runs committed in benchmarks/campaign/) put the whole change at one boundary: Chromium 149 made the JS core 3.36× faster while the WASM path stayed flat, moving the raw-kernel ratio from 0.26 to 0.88. Builds 143 through 148 still reproduce the table above, which validates the old measurement; 149 and 151 do not. So the 4× was real, and it expired when V8 improved underneath it. A performance claim about a JIT-compiled host is a claim about a specific engine build: ADR 0003 now carries a dated addendum, and every place the docs said ~4× now names the build range.
Belief three: WebGPU is the endgame
This one was the most embarrassing.
The repository had carried five WGSL compute-shader drafts since the original specification. None was connected to a public dispatch path.
At one point, the documentation claimed that WebGPU was approximately 10× faster than WASM on a million-point input and would be selected automatically.
There was no WebGPU path.
Not a slow one. None.
The unsupported claim was removed, leaving a choice: delete the shaders or wire up the most plausible one and measure it.
We wired bin2d, a two-dimensional histogram. It was the most GPU-friendly candidate: embarrassingly parallel, using atomic additions with workgroup-local accumulation.
The implementation includes:
- Lazy device acquisition
- Device-loss recovery
- A cached compute pipeline
- Input upload
- Compute dispatch
- Result readback
- Silent fallback to the WASM/JavaScript kernel
The GPU path never throws into user code.
The f64 problem
WGSL does not provide f64, while vizcrush accepts Float64Array.
That matters for visualization data. A time axis often contains epoch-millisecond timestamps, thirteen-digit values. Narrowing those values directly to f32 causes nearby timestamps to collapse, because a 24-bit mantissa cannot represent millisecond differences at that magnitude.
vizcrush rebases values before narrowing:
- Find the range minimum in f64.
- Subtract it from each value.
- Convert the smaller offsets to f32.
- Compute and return bin edges in f64.
This preserves millisecond-scale distinctions across spans of roughly 2^24 milliseconds (about 4.6 hours). Floating-point rounding can still move a handful of boundary-adjacent points into a neighbouring bin, which the correctness measurements report explicitly.
Measuring the complete round trip
The benchmark measures what a CPU-array caller actually pays: f64 rebase, upload, dispatch, readback.
It ran in Chrome 150 on Apple Silicon using Metal 3, with a 256×256 grid. The harness is committed at benchmarks/webgpu-bin2d.html: serve the repo root with python3 -m http.server, open the page in a WebGPU-capable browser, and it reruns the whole table.
| Points | JavaScript core | WASM | WebGPU median | WebGPU best |
|---|---|---|---|---|
| 100K | 2.9ms | 0.6ms | 27.1ms | 10.6ms |
| 1M | 27.7ms | 3.1ms | 220.8ms | 44.7ms |
| 5M | 130.6ms | 14.6ms | 944.8ms | 202.9ms |
The GPU path was correct. At 500,000 points:
- Both paths binned all 500,000 points.
- The maximum difference in any bin was 1.
- Absolute differences summed to 14 across 65,536 cells.
- Returned edges were bit-identical.
Performance was another matter. Even when comparing WebGPU's best result against WASM's median, WASM was approximately 14–18× faster at every tested size. The recorded data is the median and minimum of ten reps per backend per size, timed end-to-end, in benchmarks/results/webgpu-bin2d.json.
That result applies to this hardware and this CPU-resident API. Upload, scheduling, and readback dominated the end-to-end call. The benchmark does not prove that WebGPU is inherently slow.
The economics could change when:
- Input data already resides on the GPU.
- Rendering consumes the grid without CPU readback.
- Several GPU operations share one upload.
- Buffer pooling reduces allocation and scheduling noise.
- Different hardware materially reduces dispatch overhead.
Those are revisit conditions, not shipping claims.
// Real, tested, and opt-in. Never auto-selected.
const grid = await bin2d(
x,
y,
{ xBins: 256, yBins: 256 },
{ backend: "webgpu" },
);
ADR 0004 records the policy:
- Ship
bin2d's WebGPU implementation as an experimental opt-in path. - Fall back silently when WebGPU cannot run.
- Never auto-select it.
- Never market it as a speedup under the current CPU-array API.
- Leave the other four shader drafts unwired until a workload justifies them.
The claims died. The launch got easier.
I expected the honesty pass to weaken the launch.
One commit removed the WebGPU speedup claim, the SIMD claim, the additional "5-10×" WASM claim, and benchmark figures that could not be traced to evidence.
On paper, the product became less impressive. In practice, it became easier to explain.
vizcrush is not a promise that WASM, SIMD, or WebGPU makes every operation faster. It is a renderer-agnostic compute layer that reduces large browser datasets into bounded typed arrays before they reach D3, Three.js, deck.gl, Canvas, WebGL, WebGPU, or another renderer.
Its performance policy is simple:
- Use the JavaScript core for small inputs.
- Use WASM when available for larger inputs.
- Fall back to JavaScript when WASM cannot load.
- Let callers force either path.
- Keep WebGPU opt-in for
bin2d. - Publish the cases where the supposedly faster technology loses.
Automatic selection does not benchmark at runtime or sniff the browser engine. For inputs below the default threshold of 1,000 elements, vizcrush uses JavaScript because crossing the WASM boundary costs more than the work it saves. For larger inputs, the automatic path attempts WASM when WebAssembly is available and falls back to JavaScript if loading fails.
Callers with workload-specific measurements can override it:
await lttb(x, y, 1_000, { backend: "js" });
await lttb(x, y, 1_000, { backend: "wasm" });
What remains is smaller and more defensible:
- A raw LTTB kernel whose ~4× Chromium win is version-scoped: real through Chromium 148, near parity once 149 improved V8, with the sweep that shows it committed
- A public API that often approaches parity after marshalling (the repo's control run records
wasm/js ≈ 1.00×) - A JavaScript core that works everywhere and performs consistently
- A correct WebGPU path whose present costs are documented
- Headline numbers linked to committed results or ADRs
- Explicit gaps where the investigation is not finished
The claims that survived are not as exciting as "GPU accelerated everywhere." They are more useful because users can trust them.
Run Backend Lab and see what your browser reports. Then browse the 42 runnable examples or read the production adoption guide. If you try Backend Lab, post your browser, engine, input size, and WASM/JavaScript ratio in the comments; I would rather collect more measurements than repeat another belief.










