Large-Dataset Rendering Strategies

Past a few thousand marks, the bottleneck stops being the GPU and becomes the number of things you ask the browser to touch per frame, so the winning move is to draw fewer of them.

This topic is the volume-scaling companion within the high-performance animation and GPU acceleration overview: where that guide budgets the 16.67ms frame, this one decides what data even enters the frame. The governing idea is that a display has a bounded number of pixels — a 1200px-wide chart cannot show more than 1200 distinct x-positions — so rendering ten million rows one-to-one wastes almost all of that work on marks that overplot into invisibility. Every strategy here is a way to reduce the data to the resolution the screen and the human eye can actually resolve, while preserving the shape, extremes, and density that make the chart truthful. You pick the reduction that matches your mark type: downsampling for lines, aggregation for scattered points, and virtualization for tall lists of bars.

The large-dataset rendering pipeline Raw data flows through a reduction stage of downsampling, aggregation, or windowing, then a level-of-detail decision, then a renderer chosen by mark count. Raw data to pixels: reduce before you render Raw source 10k to 10M rows Reduce downsample (LTTB) aggregate / bin window / tile Level of detail by zoom / density Render Canvas / WebGL Point count sets the renderer < 1k marks SVG is fine 1k to 100k Canvas 2D > 100k WebGL Reduction happens once per view change, not once per frame
Raw rows are reduced by the strategy that matches the mark type, resolved to a level of detail for the current zoom, then handed to the renderer the point count demands.

When to use what

The first decision is almost always driven by how many marks survive reduction, and by the mark’s geometry. A time series is a single connected path, so it downsamples cleanly; a scatter is a cloud, so it aggregates; a bar list is discrete and ordered, so it virtualizes. The table pairs a working point-count band with the strategy and the renderer it implies.

Marks after reduction Data shape Strategy Renderer Notes
< 1,000 any render directly SVG Full interactivity, per-node events, no reduction needed
1k–10k line / time series LTTB downsample to ~2× width Canvas 2D Preserve peaks; re-run on zoom
10k–100k scatter cloud bin into 2D grid or hexbin Canvas 2D Draw density, not individual points
10k–100k ordered list virtualize / windowing DOM or Canvas Draw only the visible slice
100k–1M scatter / line aggregate + level-of-detail WebGL One buffer upload; shader draws all
1M–10M any tiling + aggregation WebGL + workers Pre-aggregate server-side or in a worker

The renderer column is a floor, not a ceiling: a crisp DPR-correct Canvas holds up to roughly 100k simple marks per frame, and beyond that you cross into WebGL for 100k scatter points without frame drops. The switch is not free — you trade the 2D API’s per-mark convenience for buffer management — so make it only when the point count forces it.

Per-frame time vs the 16.7ms budget Measured-style frame durations with bars over budget marked in red. frame #ms/frame16.7ms budget (60fps)
Frame durations for large against the 16.7ms (60fps) budget — the red bars are the janky frames a fix must remove.

Core concepts

Four reductions cover almost every case, and they compose. Downsampling keeps a representative subset of the original samples, chosen to preserve visual features; the Largest-Triangle-Three-Buckets algorithm is the standard because it keeps peaks a naive stride skip would drop. Aggregation replaces many points with a summary — a count per grid cell, a mean per time bucket — collapsing an unbounded cloud into a bounded grid. Level of detail swaps between reductions based on zoom: a full LTTB pass when zoomed out, raw samples when zoomed into a small window that contains few points. Virtualization never reduces the data at all; it simply skips producing marks outside the viewport.

// A reduction is any function that maps N source rows to M <= N drawable marks.
interface Point { x: number; y: number }

type Reducer = (rows: readonly Point[], targetMarks: number) => Point[];

// The level-of-detail selector picks a reducer for the current view.
interface ViewState { widthPx: number; zoom: number; visibleRows: number }

function selectReducer(view: ViewState): Reducer {
  // PERF: target ~2 marks per horizontal pixel; more is invisible overplot.
  const target: number = Math.ceil(view.widthPx * 2);
  if (view.visibleRows <= target) return (rows) => rows.slice(); // no reduction
  if (view.zoom > 8) return (rows) => rows.slice();              // zoomed in: few rows
  return (rows, t) => lttb(rows, t);                             // zoomed out: downsample
}

// A11Y: reduction changes what is drawn, not what is announced. Keep the full,
// unreduced series available for the accessible data table and CSV export.

The invariant that keeps this correct: reduction is a function of the view, not of the frame. You recompute the reduced set when the zoom, the container width, or the underlying data changes — never inside requestAnimationFrame. Running an O(n) pass every frame reintroduces the very cost you were avoiding.

Implementation pattern

class LargeSeriesChart {
  private reduced: Point[] = [];
  private rawRows: readonly Point[] = [];

  setData(rows: readonly Point[]): void {
    this.rawRows = rows;
    this.reduce(); // reduce on data change, not per frame
  }

  onViewChange(view: ViewState): void {
    const reducer: Reducer = selectReducer(view);
    // PERF: cache the reduced marks; the draw loop reads this array only.
    this.reduced = reducer(this.rawRows, Math.ceil(view.widthPx * 2));
    this.requestDraw();
  }

  private reduce(): void { /* initial pass with a default view */ }
  private requestDraw(): void { /* schedule a single rAF draw of this.reduced */ }
}
Drag the node-count slider. Every SVG mark is a live DOM node, so the node count climbs linearly and layout cost with it; the Canvas stays one element no matter how many points it draws. This is the mechanical reason the table above switches renderers as marks grow — the DOM, not the pixels, is what saturates first.

Performance & memory

The dominant cost of a naive large chart is not arithmetic but allocation and DOM. Ten million {x, y} objects is roughly 400–600MB of heap once you count object headers and hidden-class overhead, and it churns the garbage collector on every rebuild. Store coordinates in Float32Array or Float64Array columns instead: two typed arrays of ten million floats are 80MB and 160MB respectively, contiguous, and free of per-element GC pressure. Reduction itself is cheap when done right — LTTB is O(n) in a single pass, binning is O(n) into a fixed grid — so the whole reduce step is one linear scan on a view change, well under a frame even for a million rows.

The frame budget then only pays for the reduced set. At two marks per pixel a 1200px chart draws about 2,400 marks per frame regardless of whether the source was ten thousand rows or ten million, which is why a correctly reduced ten-million-point series animates as smoothly as a small one. Uploading to WebGL is the one place to watch: call bufferData once per reduction and bufferSubData for appends, never per frame, or the CPU-to-GPU transfer becomes the new bottleneck.

Accessibility checklist

Troubleshooting

  • Peaks vanish when zoomed out → the reducer drops extremes → use LTTB rather than every-Nth-point stride sampling, which skips spikes between strides.
  • Chart stutters during pan → reduction runs inside the frame loop → move the reduce call to the view-change handler and cache its output in a typed array.
  • Colours look flat in dense scatter → linear colour scale saturates → switch to a perceptually uniform scale and consider a log density transform for heavy-tailed counts.
  • Memory climbs on each update → arrays of point objects are reallocated → hold columns in reusable typed arrays and overwrite in place.
  • WebGL frame time spikes on append → the whole buffer re-uploads every frame → upload once and use bufferSubData for the appended tail only.

Frequently Asked Questions

How many points can I draw before I need to reduce the data?

As a working rule, SVG stays responsive to about one thousand interactive marks, a DPR-correct Canvas to roughly one hundred thousand simple marks per frame, and WebGL into the millions. But the display resolves at most one distinct mark per horizontal pixel for a line, so past a few thousand points you are overplotting regardless of renderer — reduce for truthfulness and speed together.

Should I downsample or aggregate my data?

Downsample connected series like time-series lines, where LTTB keeps a representative subset of the real samples and preserves peaks. Aggregate scattered clouds, where binning counts points per cell and draws a density heatmap instead of overplotting thousands of overlapping dots. The mark’s geometry decides: a path downsamples, a cloud aggregates.

Where should the reduction run so it does not cost a frame?

Run it in the view-change handler — on zoom, resize, or data append — and cache the result in a typed array. The per-frame draw loop should only read that cached buffer. Reducing inside requestAnimationFrame reintroduces an O(n) scan every 16.67ms, which is exactly the cost the reduction was meant to remove.