Downsampling Time Series with LTTB

Your line chart plots half a million samples across twelve hundred pixels, the tab pins a core, and the spikes you actually care about look identical whether or not you thin the data — so why draw all of it?

The answer is that you should not, and how you thin it decides whether the chart stays honest. This page is a companion within large-dataset rendering strategies: it is the reduction to reach for when the mark is a connected line. The Largest-Triangle-Three-Buckets (LTTB) algorithm keeps a small, representative subset of the original samples — never synthetic averages — chosen so the downsampled path traces the same silhouette as the full one, peaks and troughs intact. It is the standard because the obvious alternatives fail visibly: taking every Nth point drops any spike that falls between strides, and averaging each bucket flattens the extremes into a muted mean.

Diagnostic checklist

LTTB triangle selection across three buckets The previous selected point, every candidate in the current bucket, and the average of the next bucket form triangles; the candidate giving the largest area is kept. Keep the point forming the largest triangle current bucket next bucket A: prev kept B: candidate (peak) C: next avg Largest area wins, so the tall peak B is retained over its flat neighbours
For each bucket, LTTB forms a triangle from the last kept point, each candidate, and the next bucket's average; the candidate with the greatest triangle area is the one kept, which favours extremes.
Downsampling with LTTB Twelve hundred raw samples reduced to ninety with Largest-Triangle-Three-Buckets, preserving the peaks a naive stride would drop. sample indexvalueraw (1200 pts)LTTB (90 pts)
Raw series vs. the LTTB-reduced series driving time series with lttb: the algorithm keeps the visually significant extrema while cutting the point count by an order of magnitude.

Broken vs fixed

// ❌ BROKEN: every-Nth-point stride sampling silently drops peaks.
function strideSample(rows: readonly Point[], target: number): Point[] {
  const step: number = Math.ceil(rows.length / target);
  const out: Point[] = [];
  for (let i = 0; i < rows.length; i += step) {
    out.push(rows[i]); // a spike between i and i+step is never seen
  }
  return out;
}
// ✅ FIXED: LTTB keeps the sample that best preserves the visual shape.
interface Point { x: number; y: number }

function lttb(rows: readonly Point[], target: number): Point[] {
  const n: number = rows.length;
  if (target >= n || target < 3) return rows.slice();

  const sampled: Point[] = [rows[0]];          // always keep the first point
  const bucketSize: number = (n - 2) / (target - 2);
  let a = 0;                                    // index of the last kept point

  for (let i = 0; i < target - 2; i++) {
    // Average of the NEXT bucket forms the far vertex of each triangle.
    const nextStart: number = Math.floor((i + 1) * bucketSize) + 1;
    const nextEnd: number = Math.min(Math.floor((i + 2) * bucketSize) + 1, n);
    let avgX = 0, avgY = 0;
    for (let j = nextStart; j < nextEnd; j++) { avgX += rows[j].x; avgY += rows[j].y; }
    const span: number = nextEnd - nextStart || 1;
    avgX /= span; avgY /= span;

    // Scan the current bucket for the point with the largest triangle area.
    const curStart: number = Math.floor(i * bucketSize) + 1;
    const curEnd: number = Math.floor((i + 1) * bucketSize) + 1;
    let maxArea = -1, chosen = curStart;
    const ax: number = rows[a].x, ay: number = rows[a].y;
    for (let j = curStart; j < curEnd; j++) {
      // PERF: twice the signed area; the 0.5 factor is irrelevant to the argmax.
      const area: number = Math.abs((ax - avgX) * (rows[j].y - ay) - (ax - rows[j].x) * (avgY - ay));
      if (area > maxArea) { maxArea = area; chosen = j; }
    }
    sampled.push(rows[chosen]);
    a = chosen;
  }
  sampled.push(rows[n - 1]);                     // always keep the last point
  // A11Y: `rows` (the full series) stays intact for the accessible table export.
  return sampled;
}

The mechanism is the triangle. For each output bucket the algorithm considers three vertices: the point it kept last, each candidate in the current bucket, and the mean position of the next bucket. The candidate that forms the largest-area triangle with those two fixed vertices is the one that deviates most from a straight line between its neighbours — which is exactly the peak, trough, or inflection a human eye reads as a feature. Averaging would place a vertex at the mean; stride sampling ignores area entirely. Anchoring the first and last samples keeps the endpoints pinned so the reduced line spans the same domain.

Step-by-step fix

function downsampleForView(rows: readonly Point[], plotWidthPx: number): Point[] {
  const target: number = Math.max(3, Math.ceil(plotWidthPx * 2));
  return lttb(rows, target); // run on view change, cache the return value
}

Verification

const raw: Point[] = Array.from({ length: 100_000 }, (_, i) => ({ x: i, y: Math.sin(i / 500) }));
raw[73_412] = { x: 73_412, y: 99 };               // inject a lone spike
const out: Point[] = lttb(raw, 1_000);
console.assert(out.length === 1_000, `expected 1000 marks, got ${out.length}`);
console.assert(out[0].x === 0 && out[out.length - 1].x === 99_999, "endpoints not anchored");
console.assert(out.some((p) => p.y === 99), "LTTB dropped the injected peak");

In DevTools, overlay the raw and downsampled paths at low opacity: the two silhouettes should be indistinguishable at chart scale, and the spike must be present in both. Compare against a stride-sampled overlay to see the peak disappear from the naive version.

When to downsample versus aggregate

LTTB is the right tool when the data is a single-valued function of one axis — a metric over time, a sensor reading, a price. The line has one y for each x, so keeping a representative x-ordered subset reproduces the curve. It is the wrong tool for a scattered cloud where many points share similar x-values with different y-values, because there is no single line to preserve; thinning a cloud with LTTB just discards points arbitrarily. That case wants aggregation into a density heatmap, which summarises how many points fall in each region rather than trying to trace a path through them.

There is also a middle ground: multi-series line charts. Run LTTB independently per series against a shared target, because a peak in one series has no bearing on which sample best represents another. Sharing one triangle scan across interleaved series corrupts every line.

Edge cases & gotchas

  • Fewer rows than the target. When target >= n there is nothing to reduce; return a copy of the input rather than running the bucket maths on empty ranges.
  • Irregular timestamps. LTTB scores on triangle area, which uses the real x-values, so uneven sampling is handled correctly without resampling to a fixed grid first.
  • NaN or gap values. A NaN y-value poisons the area comparison and can hijack the argmax. Split the series at gaps and downsample each contiguous run separately.
  • Zoom without re-running. A downsample computed for the full domain looks coarse when the user zooms into a narrow window; re-run against the visible slice or the detail never appears.
  • Downsampling in the frame loop. LTTB is O(n) but that is still far too much to run every 16.67ms; compute it on view change and cache the array.
  • Colour-coded thresholds. If a point’s colour encodes a category, LTTB may drop the exact sample that crossed a threshold; keep threshold crossings as forced-retain points alongside the algorithm’s picks.
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 time series with lttb against the 16.7ms (60fps) budget — the red bars are the janky frames a fix must remove.

Frequently Asked Questions

Why does LTTB keep peaks that averaging loses?

Because it selects an actual sample by largest triangle area rather than computing a bucket mean. A tall spike forms a large-area triangle against its neighbours, so it wins its bucket and is retained. Averaging blends the spike into the surrounding values and pulls the plotted point toward the mean, flattening the feature.

How many output points should I target?

Tie the target to the plot’s pixel width — roughly two points per horizontal pixel. A 1200px chart therefore targets about 2400 samples, which is more than the display can resolve as distinct verticals, so the downsampled line is visually lossless while the source could be millions of rows.

Do I need to re-run LTTB when the user zooms in?

Yes. A downsample computed for the whole domain is coarse inside a narrow zoom window. Recompute LTTB against the currently visible slice on each zoom or pan across a boundary so finer structure appears as the user drills in, then cache that result for the frame loop.