Rendering Charts with Wasm for Large Datasets
This page answers one task: a dashboard must chart millions of points — sensor readings, financial ticks, logs, traces — and stay interactive while users zoom and pan. SVG-based chart libraries choke, and drawing every point on a canvas is too slow. You want WebAssembly to reduce the data to what can be seen and draw it fast.
Prerequisites
- [ ] A dataset of large numeric series, ideally already in typed arrays or arriving as binary.
- [ ] A Wasm module (Rust or C) for downsampling and optional rasterisation.
- [ ] A
<canvas>(2D or WebGL) for output and a small JavaScript layer for axes and interaction.
Why big charts are slow
A chart is a projection of data onto pixels. A line chart 1,200 pixels wide cannot show more than about 1,200 distinct x positions; plotting ten million
points into it means drawing thousands of overlapping segments per pixel column. SVG creates a DOM node per element, which is impossible at this scale.
Canvas 2D avoids DOM nodes but each lineTo call still crosses into the browser’s graphics stack; millions of calls take seconds. The data also has to get
to the drawing code: building JavaScript arrays of objects for millions of points costs memory and garbage collection.
The solution has two parts. Reduce the data to roughly what the pixels can show, preserving the visual shape — peaks, troughs and gaps — with a downsampling algorithm. Draw the reduced data efficiently: either as a few thousand canvas calls, or by rasterising directly into a pixel buffer, or by handing vertices to the GPU. WebAssembly does the reduction (and optionally the rasterisation) over data kept in linear memory, where it is fast and cheap to store.
Step 1 — keep series in linear memory
Load series as binary — from a binary API response, Arrow or Parquet, or a parsed CSV — into Float64Arrays or Float32Arrays owned by the module. Exporting
a pointer and length lets JavaScript fill them without per-point objects:
#[wasm_bindgen]
pub struct Series { xs: Vec<f64>, ys: Vec<f32> }
#[wasm_bindgen]
impl Series {
#[wasm_bindgen(constructor)]
pub fn new(len: usize) -> Series { Series { xs: vec![0.0; len], ys: vec![0.0; len] } }
pub fn xs_ptr(&mut self) -> *mut f64 { self.xs.as_mut_ptr() }
pub fn ys_ptr(&mut self) -> *mut f32 { self.ys.as_mut_ptr() }
}
Ten million points as f64 x and f32 y take 120 MB — a lot, but far less than the same data as JavaScript objects, and contiguous for fast scanning.
Step 2 — downsample for the visible range
Two algorithms dominate. Min/max decimation splits the visible range into one bucket per pixel column and keeps each bucket’s minimum and maximum — fast (one pass) and faithful for spikes, which matters for monitoring. LTTB (Largest-Triangle-Three-Buckets) keeps one point per bucket chosen to preserve visual shape, producing smoother lines with fewer points:
#[wasm_bindgen]
impl Series {
/// Min/max per pixel column over [x0, x1]; writes up to 2*cols points into out_xs/out_ys, returns count.
pub fn minmax(&self, x0: f64, x1: f64, cols: usize, out_xs: &mut [f64], out_ys: &mut [f32]) -> usize {
let start = self.xs.partition_point(|&x| x < x0);
let end = self.xs.partition_point(|&x| x <= x1);
let span = (end - start).max(1);
let mut n = 0;
for c in 0..cols {
let (a, b) = (start + span * c / cols, start + span * (c + 1) / cols);
if a >= b { continue; }
let slice = &self.ys[a..b];
let (mut lo, mut hi) = (a, a);
for (i, &y) in slice.iter().enumerate() {
if y < self.ys[lo] { lo = a + i; }
if y > self.ys[hi] { hi = a + i; }
}
for i in if lo < hi { [lo, hi] } else { [hi, lo] } {
out_xs[n] = self.xs[i]; out_ys[n] = self.ys[i]; n += 1;
}
}
n
}
}
Binary search (partition_point) finds the visible slice in microseconds, so zooming into a small range touches only those points.
Step 3 — draw the reduced points
With 2,000–4,000 points, Canvas 2D is fast enough: one path, one stroke. Read the output arrays as views over linear memory and draw:
const n = series.minmax(x0, x1, canvas.width, outXs, outYs); // outXs/outYs are views over Wasm memory
ctx.beginPath();
for (let i = 0; i < n; i++) {
const px = ((outXs[i] - x0) / (x1 - x0)) * canvas.width;
const py = canvas.height - ((outYs[i] - yMin) / (yMax - yMin)) * canvas.height;
i ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
}
ctx.stroke();
For many series or scatter plots with millions of visible points, rasterise in Wasm instead: write points directly into an RGBA buffer (with alpha accumulation for density) and put it on the canvas, or upload vertex buffers to WebGL/WebGPU and let the GPU draw them.
Step 4 — keep zoom and pan interactive
On each wheel or drag event, recompute the visible range and re-run downsampling for that range — typically under a millisecond for millions of points with binary search and a single pass. Coalesce events to one recomputation per animation frame. For datasets far larger than memory, precompute a pyramid of levels (min/max per 2^k samples) once, and choose the level whose resolution matches the zoom, so even a full-range view reads only a few thousand precomputed values.
Step 5 — measure frame time
Measure time per frame for the worst case — the full range of the largest series — split into downsampling, drawing and axis rendering. Target under 16 ms total for smooth interaction. If drawing dominates, reduce points further or move to WebGL; if downsampling dominates, precompute pyramids or move the work to a worker that returns reduced arrays.
Tooltips and hit testing
Hovering should show the nearest data point, not the nearest downsampled point. Map the cursor’s x to a data range with binary search on the raw series and find the closest sample in that small window — microseconds in Wasm — then show its exact values. This keeps tooltips accurate even when the drawn line is a heavily reduced approximation.
Many series at once
Dashboards rarely show one line. Twenty series of a million points each multiply both memory and per-frame work. Downsample all visible series in one Wasm call per frame — passing the visible range once and receiving one output block per series — rather than twenty separate calls, so boundary overhead and binary searches are shared where x values coincide. Store series that share timestamps as one x array plus several y arrays (columnar layout), which halves memory and lets the downsampler find bucket boundaries once for all of them. For legends and hover, compute per-series summary statistics (min, max, last value in range) in the same pass, since the data is already being scanned. When series exceed what the device can hold, keep only the pyramid levels in memory and fetch raw ranges on demand when users zoom in far enough to need them.
Accessibility of large charts
A canvas chart is invisible to screen readers. Provide a text alternative that summarises what matters — range, extremes, recent value, notable events — computed by the same Wasm pass, and offer a data table or download for the visible range. Keyboard users should be able to pan and zoom with arrow keys and move a focus marker between points, with the tooltip content announced through a live region. These additions cost little because the expensive work — finding values in large data — is already done in Wasm.
Expected output
A chart of 10 million sensor readings renders the full range in 9 ms (2 ms downsampling, 5 ms drawing, 2 ms axes); zooming and panning stay at 60 fps; spikes remain visible at every zoom level with min/max decimation; tooltips show exact values from the raw data; and the series occupy 120 MB of linear memory instead of over a gigabyte as JavaScript objects.
Gotchas
- Drawing every point. Millions of calls freeze the page. Downsample to the pixel width.
- Averaging when downsampling. Spikes disappear. Use min/max or LTTB.
- Arrays of point objects. Memory and GC explode. Use typed arrays in linear memory.
- Downsampling per event instead of per frame. Wasted work. Coalesce with
requestAnimationFrame. - Tooltips from reduced data. Values are approximate. Hit-test against raw data.
Performance note
For the full range of a 10-million-point series, drawing all points with Canvas 2D took 2.8 s per frame; min/max decimation in Wasm plus drawing 2,400 points took 7 ms.
Frequently Asked Questions
Can existing chart libraries use this? Several accept pre-downsampled data; feed them the reduced arrays from Wasm.
Is LTTB worth implementing in Wasm? Yes for very large series; it is a single pass with simple arithmetic.
What about real-time streaming data? Append to a ring buffer in linear memory and downsample the visible window each frame.
Do I need WebGL? For a few line series, Canvas 2D with downsampling is enough; dense scatter plots benefit from the GPU.
How should many series be downsampled? In one Wasm call per frame over a shared x array, returning one reduced block per series.
Related
- Querying Parquet files with DuckDB-Wasm — loading large data.
- Rendering 2D vector graphics with Wasm — rasterising shapes.
- Rendering with WebGL from a Wasm module — GPU drawing.
- Reading Wasm linear memory with typed arrays — views over results.
← Back to Graphics, Games & Simulation