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.

From ten million points to a drawn chart Raw samples sit in linear memory as typed arrays. For the visible range and canvas width, Wasm selects the visible slice and downsamples it with min-max decimation or LTTB to a few thousand points. JavaScript draws the reduced polyline, or Wasm rasterises it into a pixel buffer that is put on the canvas. 10M samples in linear memory visible range slice binary search on x downsample in Wasm min/max or LTTB ~2–4k points per canvas width draw or rasterise canvas / WebGL

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.

Min/max decimation versus LTTB Min/max decimation keeps the minimum and maximum of each pixel column, guaranteeing that spikes remain visible, in a single fast pass. LTTB keeps one representative point per bucket chosen to preserve visual shape, producing smoother lines with half the points but can soften isolated spikes. min/max per column every spike visible one pass, very fast 2 points per column monitoring, alerts LTTB shape-preserving 1 point per bucket may soften lone spikes trends, overviews

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.

Frame time for a 10-million-point line chart Milliseconds per frame to draw the full range of a 10 million point series with every point drawn on Canvas 2D, and with min max decimation in Wasm followed by drawing 2,400 points. ms per frame draw every point 2,800 ms Wasm min/max + 2, 400 points 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.

← Back to Graphics, Games & Simulation