Dynamic data, streaming and performance

Push and shift points without re-creating the chart, use update modes that skip the work you do not need, and keep a live chart smooth.

Mutate, then update

const chart = new Chart(canvas, {
  type: 'line',
  data: { labels: [], datasets: [{ label: 'Requests', data: [], tension: 0.3 }] },
  options: { animation: false, responsive: true, maintainAspectRatio: false, parsing: false }
});

// The supported pattern: change the data, then tell the chart.
function pushPoint(label, value) {
  chart.data.labels.push(label);
  chart.data.datasets[0].data.push(value);

  // Keep a fixed window so memory and render cost stay constant.
  const WINDOW = 120;
  if (chart.data.labels.length > WINDOW) {
    chart.data.labels.shift();
    chart.data.datasets[0].data.shift();
  }

  chart.update('none');       // no animation, no y-axis rescale
}

// Update modes and what each one skips
// chart.update()                    full update: re-parse, rescale, re-render, animate
// chart.update('none')              re-render without animation
// chart.update('resize')            the container changed size
// chart.update('reset')             replay animations from the start
// chart.update('show') / ('hide')   reveal or hide the whole chart
// chart.update('active')            re-render the hover state only

// Adding a whole dataset
chart.data.datasets.push({ label: 'Errors', data: errorWindow, borderColor: '#ef4444' });
chart.update();

// Removing one: also remove the legend entry and any custom plugin state
chart.data.datasets.splice(1, 1);
chart.update();
SituationCallWhy
A point arrived from a socketupdate('none')Animation on a live chart is noise
The user changed a filterupdate()A transition helps the change read
Only the hover state changedupdate('active')Cheapest possible re-render
The container was resizedresize()Recomputes the layout and the device ratio
Sweeping a whole new dataset inReplace the array, then update()Reuse the dataset object
High-frequency dataBatch per animation frameNever update per message
💡
Chart.js 4 has a default animation of about one second on update(). On a chart that receives data every second, that animation never finishes and the result is a permanently lagging, wobbly line. Set animation: false for anything streaming.

Streaming without dropping frames

// Batch incoming points and render at most once per frame.
class StreamBuffer {
  constructor(chart, { window = 200, datasetIndex = 0 } = {}) {
    this.chart = chart;
    this.window = window;
    this.datasetIndex = datasetIndex;
    this.pending = [];
    this.frame = null;
  }

  push(point) {
    this.pending.push(point);
    // requestAnimationFrame coalesces a burst into a single render.
    if (this.frame === null) this.frame = requestAnimationFrame(() => this.flush());
  }

  flush() {
    this.frame = null;
    if (!this.pending.length) return;

    const dataset = this.chart.data.datasets[this.datasetIndex];
    for (const point of this.pending) dataset.data.push(point);
    this.pending.length = 0;

    if (dataset.data.length > this.window) {
      dataset.data.splice(0, dataset.data.length - this.window);
    }

    this.chart.update('none');
  }

  stop() {
    if (this.frame !== null) cancelAnimationFrame(this.frame);
    this.frame = null;
    this.pending.length = 0;
  }
}

const buffer = new StreamBuffer(chart, { window: 300 });
socket.addEventListener('message', (event) => buffer.push(JSON.parse(event.data)));
// Performance options on the chart itself
const options = {
  animation: false,
  responsive: true,
  maintainAspectRatio: false,
  parsing: false,                     // the data is already {x, y}
  normalized: true,                   // the data is sorted by x
  spanGaps: false,

  elements: {
    point: { radius: 0, hitRadius: 6 },   // no visible points on a live line
    line: { borderWidth: 1.5 }
  },

  plugins: {
    decimation: {
      enabled: true,
      algorithm: 'lttb',              // Largest-Triangle-Three-Buckets
      samples: 500,                    // target points to keep
      threshold: 1000                  // only decimate above this many points
    },
    legend: { display: false }
  },

  scales: {
    x: { type: 'linear', ticks: { maxTicksLimit: 6, autoSkip: true } },
    y: { beginAtZero: false, ticks: { maxTicksLimit: 5 } }
  }
};

// Decimation must be registered before it can be used:
// import { Decimation } from 'chart.js';
// Chart.register(Decimation);
  • parsing: false is the single biggest win for a large series: Chart.js stores your objects instead of walking them to build an internal form.
  • normalized: true tells Chart.js the data is already sorted, which skips the internal sort. Unsorted data with this flag produces a scrambled line.
  • Decimation keeps the shape of a spike rather than averaging it away, which matters for monitoring data. It does not reduce the stored array — only what is drawn.
  • A zero point radius is what makes a 1,000-point line look like a line rather than a caterpillar.

Duplicate charts and other leaks

// A chart instance holds listeners and a ResizeObserver. Destroying is not optional.
// The wrong pattern: recreate on every data change.
function renderWrong(data) {
  new Chart(canvas, { type: 'line', data });       // a new instance every call
}

// The right pattern: create once, update many times.
let chart = null;
function render(data) {
  if (!chart) {
    chart = new Chart(canvas, { type: 'line', data, options });
  } else {
    chart.data = data;
    chart.update();
  }
}

// Teardown when the chart is no longer needed
function destroyChart() {
  chart?.destroy();       // removes listeners, the observer and the canvas state
  chart = null;
}

// Detecting the mistake: Chart.js warns when an instance already exists for a
// canvas. The warning is easy to miss, and the visible symptom is a chart that
// animates twice as fast or a doubling of the hover tooltip.
SymptomCauseFix
Animations look doubledTwo instances on one canvasdestroy() before re-creating, or reuse
Memory grows on navigationInstances kept after the element is goneDestroy in the component's teardown
Hover highlights the wrong pointsStale dataset references after mutating arraysUpdate through chart.data
The chart flickers on each updateAnimation on a streaming chartanimation: false
A CPU spike every secondOne update() per messageBatch into a requestAnimationFrame
The line jumps when old points fall offbeginAtZero or a rescaling axisFix min and max, or use suggestedMin
// A stable y axis: the line stops jumping when a new maximum arrives.
const stableAxis = {
  scales: {
    y: {
      // A fixed scale is easier to read than an auto-scaling one, but you must
      // choose the bounds. A rolling window is a good compromise.
      suggestedMin: 0,
      suggestedMax: 1000,
      ticks: { maxTicksLimit: 5 }
    }
  }
};

// Or compute the window from the data with hysteresis, so the axis only grows
// when the data really needs it.
let axisMax = 100;
function updateAxis(chart) {
  const peak = Math.max(...chart.data.datasets[0].data.map((p) => p.y ?? p), 0);
  if (peak > axisMax) axisMax = Math.ceil(peak * 1.1 / 100) * 100;   // grow in steps
  chart.options.scales.y.max = axisMax;
}

FAQ

Why does update() feel slow?
The default update re-parses the data, recomputes the scales and animates the change. For a live chart set animation: false and parsing: false, use update('none'), and batch points into a single animation frame instead of calling update per message.
How do I keep a fixed-size scrolling window?
Push the new point and shift from the front once the array exceeds your limit, then call update('none'). Keep the same array and dataset objects — replacing them forces a full re-parse.

Responsive canvas layout and image export Testing charts and migrating to v4

Last refreshed 2026-09-18.