Back to Articles
2026 / 02
| 4 min read

Deep Dive: Custom Canvas Charts for 30fps Animation

Why Chart.js couldn't keep up with streaming data, and how a custom Canvas 2D renderer with data decimation achieved smooth 30fps playback.

code-evolution-analyzer canvas performance javascript visualization

Deep Dive: Custom Canvas Charts for 30fps Animation

Replacing Chart.js when animation performance matters


Chart.js is fantastic. It handles responsive layouts, touch interactions, tooltips, legends, and dozens of chart types out of the box. For static charts or occasional updates, it’s the obvious choice.

For streaming animations at 30fps? Not so much.

The Problem

The Code Evolution Analyzer plays back repository history as an animation. Each frame advances to a new commit, adding data points to the chart. At 30fps with 2,000 commits, that’s adding 60,000 data points per playback cycle.

Chart.js redraws the entire chart on every update. With 16 language lines and 2,000 points each, that’s 32,000 path operations per frame. The animation stuttered badly—maybe 10fps on a good machine, completely unusable on mobile.

I tried the standard optimizations:

// Disable animations
chart.options.animation = false;

// Reduce tension (simpler curves)
dataset.tension = 0;

// Disable point rendering
dataset.pointRadius = 0;

// Batch updates
chart.update('none');

Better, but still not 30fps. The fundamental issue is architectural: Chart.js wasn’t designed for high-frequency updates with large datasets.

The O(n²) Bug

Before replacing Chart.js entirely, I found an algorithmic bug in my own code. I was rebuilding the entire dataset on every frame:

// BEFORE: O(n²) - rebuilding all data every frame
function updateChart() {
  const datasets = [];
  
  for (const lang of ALL_LANGUAGES) {
    const data = [];
    for (let i = 0; i <= currentIndex; i++) {
      data.push(DATA[i].languages[lang]?.code || 0);
    }
    datasets.push({ label: lang, data });
  }
  
  chart.data.datasets = datasets;
  chart.update('none');
}

For 2,000 commits and 16 languages, that’s creating 32,000 new array elements every frame. Even without Chart.js rendering, the garbage collector was killing performance.

The fix was incremental updates:

// AFTER: O(n) - only append new data
let lastChartIndex = -1;

function updateChart() {
  // Reset if going backwards
  if (currentIndex < lastChartIndex) {
    // ... rebuild from scratch
    lastChartIndex = -1;
  }
  
  // Append only new points
  for (let i = lastChartIndex + 1; i <= currentIndex; i++) {
    for (let j = 0; j < ALL_LANGUAGES.length; j++) {
      const lang = ALL_LANGUAGES[j];
      const value = DATA[i].languages[lang]?.code || 0;
      chart.data.datasets[j].data.push(value);
    }
    chart.data.labels.push((i + 1).toString());
  }
  
  lastChartIndex = currentIndex;
  chart.update('none');
}

This helped, but Chart.js still couldn’t render the growing dataset fast enough.

Custom Canvas Renderer

I wrote a replacement renderer using the Canvas 2D API directly. No abstractions, no flexibility, just fast line drawing:

const chartCanvas = document.getElementById('chart-canvas');
const chartCtx = chartCanvas.getContext('2d');

function renderChart() {
  const ctx = chartCtx;
  
  // Clear canvas
  ctx.fillStyle = '#0d1117';
  ctx.fillRect(0, 0, chartWidth, chartHeight);
  
  // Find value range
  let maxValue = 0;
  for (let i = 0; i <= currentIndex; i++) {
    for (const lang of ALL_LANGUAGES) {
      const val = DATA[i].languages[lang]?.code || 0;
      if (val > maxValue) maxValue = val;
    }
  }
  maxValue *= 1.1; // 10% headroom
  
  // Draw each language line
  for (const lang of ALL_LANGUAGES) {
    ctx.strokeStyle = LANGUAGE_COLORS[lang];
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    
    for (let i = 0; i <= currentIndex; i++) {
      const val = DATA[i].languages[lang]?.code || 0;
      const x = PADDING_LEFT + (i / currentIndex) * plotWidth;
      const y = plotHeight - (val / maxValue) * plotHeight + PADDING_TOP;
      
      if (i === 0) {
        ctx.moveTo(x, y);
      } else {
        ctx.lineTo(x, y);
      }
    }
    
    ctx.stroke();
  }
}

Raw Canvas 2D is surprisingly fast. For 2,000 points × 16 lines, the browser’s native path rendering handles it easily. But there’s still a problem: as the animation plays, the dataset grows. By the end of playback, we’re rendering 32,000 line segments every frame.

Data Decimation

The solution is decimation: don’t render every point. Human eyes can’t distinguish 2,000 points on a 1,000-pixel-wide canvas anyway—you’re limited by display resolution.

const MAX_RENDER_POINTS = 800;

function renderChart() {
  const totalPoints = currentIndex + 1;
  const step = Math.max(1, Math.ceil(totalPoints / MAX_RENDER_POINTS));
  
  for (const lang of ALL_LANGUAGES) {
    ctx.beginPath();
    let firstPoint = true;
    
    // Sample every 'step' points
    for (let i = 0; i <= currentIndex; i += step) {
      const val = DATA[i].languages[lang]?.code || 0;
      const x = PADDING_LEFT + (i / currentIndex) * plotWidth;
      const y = plotHeight - (val / maxValue) * plotHeight + PADDING_TOP;
      
      if (firstPoint) {
        ctx.moveTo(x, y);
        firstPoint = false;
      } else {
        ctx.lineTo(x, y);
      }
    }
    
    // Always include the current point for accuracy
    if (step > 1) {
      const val = DATA[currentIndex].languages[lang]?.code || 0;
      ctx.lineTo(plotWidth + PADDING_LEFT, /* calculated y */);
    }
    
    ctx.stroke();
  }
}

With 800 max points, the rendering cost is bounded regardless of dataset size. A 10,000-commit repository renders as fast as a 100-commit one.

The “always include current point” trick is important: without it, the right edge of the chart jumps around as decimation samples different points. Including the actual current value keeps the leading edge smooth.

High-DPI Handling

Modern displays have 2x or 3x pixel density. Naive canvas rendering looks blurry:

// BLURRY: Canvas at CSS size
canvas.width = rect.width;
canvas.height = rect.height;

The fix is to scale the canvas by devicePixelRatio:

const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
chartCtx.scale(dpr, dpr);

// Drawing coordinates remain in CSS pixels
// Canvas internally uses physical pixels

On a Retina display, a 800×400 CSS canvas becomes a 1600×800 physical canvas. Lines render crisp instead of fuzzy.

Frame Rate Throttling

Even with fast rendering, updating the chart 60 times per second wastes CPU. The animation doesn’t need more than 30fps:

const CHART_UPDATE_INTERVAL_MS = 33; // ~30fps
let lastChartUpdateTime = 0;

function updateChart() {
  const now = performance.now();
  if (now - lastChartUpdateTime < CHART_UPDATE_INTERVAL_MS) {
    return; // Skip this frame
  }
  lastChartUpdateTime = now;
  
  renderChart();
}

This simple throttle cuts rendering work in half without visible quality loss.

Drawing Order for Visibility

With 16 overlapping lines, smaller languages get hidden behind larger ones. The fix is to draw from smallest to largest current value:

// Sort languages by current value (ascending)
const sorted = ALL_LANGUAGES
  .map(lang => ({
    lang,
    color: LANGUAGE_COLORS[lang],
    value: DATA[currentIndex].languages[lang]?.code || 0
  }))
  .sort((a, b) => a.value - b.value);

// Draw in order: smallest first (behind), largest last (front)
for (const { lang, color } of sorted) {
  ctx.strokeStyle = color;
  // ... draw line
}

Now the dominant language (usually JavaScript) draws on top, but minority languages remain visible underneath.

The Performance Win

MetricChart.jsCustom Canvas
100 commits45fps60fps
1,000 commits18fps58fps
5,000 commits6fps55fps
10,000 commits2fps52fps

The custom renderer maintains near-60fps regardless of dataset size. With the 30fps throttle, CPU usage drops significantly while animation stays smooth.

Trade-offs

The custom renderer lacks features Chart.js provides:

  • No tooltips: Hovering over points doesn’t show values
  • No legends: I had to build a separate legend component
  • No animations: Line transitions aren’t smoothly animated
  • No interactivity: No zoom, pan, or selection
  • Less pretty: The rendering is functional, not polished

For an animated playback visualization, these trade-offs are acceptable. The chart is a visualization of historical data, not an interactive exploration tool. Speed matters more than features.

When to Build Custom

Chart.js (or D3, or Recharts, or any charting library) is almost always the right choice. Build custom only when:

  1. You need streaming/real-time data at high frame rates
  2. Dataset size is unbounded and grows during use
  3. Interactivity isn’t needed or is handled separately
  4. You’ve tried optimizations and they’re not enough

For static charts, occasional updates, or when you need tooltips and interaction—use a library. For streaming animations where every frame counts, consider going direct.


See also: Building a Code Evolution Analyzer in a Weekend — the full project story

See also: Deep Dive: Audio Sonification — the sound design experiment