Deep Dive: Custom Canvas Charts for 30fps Animation
Chart.js redraws the full scene on every update, which is right for a dashboard and wrong for playback. Replacing it with a Canvas 2D renderer.
On this page
The stutter was subtle at first. Around commit 800 the playback would hiccup; by commit 1,500 it was unwatchable. I blamed the browser, then the dataset, then my animation loop, before I got around to suspecting the chart library.
Chart.js redraws the entire chart on every update. That’s correct for a dashboard where data arrives in batches. It’s wrong for a streaming playback where I’m adding one point per frame and expecting smooth motion. The library wasn’t broken; my model of what it was doing was.
Replacing it with a custom Canvas 2D renderer took three passes, and only one of them was about Chart.js.
The Shape of the Problem
The Code Evolution Analyzer plays back a repository’s history as an animation. Each frame advances the commit clock and adds new data to the chart — line counts, file counts, bytes — for every tracked language. The animation runs at roughly 30fps, so the chart redraws 30 times a second while the dataset grows.
At ~2,000 commits and ~16 languages, that’s tens of thousands of line segments by the end of playback. Chart.js wasn’t slow so much as thorough: a complete scene rebuild every frame, with enough garbage collector pressure on its own to cause visible stutter.
The constraint is absolute. If the renderer can’t keep up with the frame rate, the animation breaks. Axis labels, legends, and tooltips are all negotiable.
The Local Bug: O(n²) Before the Renderer
My first performance wall was self-inflicted. I was rebuilding the entire dataset on every frame:
// BEFORE: O(n²) - rebuilds all data each 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');
}
At 2,000 commits and 16 languages, that’s ~32,000 new array elements per frame before Chart.js touches the canvas.
The fix was incremental appends:
// AFTER: O(languages) per frame - only append new points
let lastChartIndex = -1;
function updateChart() {
if (currentIndex < lastChartIndex) {
// Reset when scrubbing backwards
lastChartIndex = -1;
chart.data.datasets.forEach(d => d.data = []);
chart.data.labels = [];
}
for (let i = lastChartIndex + 1; i <= currentIndex; i++) {
chart.data.labels.push(i + 1);
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);
}
}
lastChartIndex = currentIndex;
chart.update('none');
}
That removed the quadratic waste, and the render loop still stuttered as the dataset grew. Chart.js was receiving minimal new data but still walking the entire dataset on every repaint.
The Renderer: Canvas 2D With Bounded Work
So I replaced Chart.js with a direct Canvas 2D renderer. The trade is straightforward: I lose declarative configuration and gain control over which pixels get touched each frame.
What’s left is “draw lines for N languages across M points,” which browsers handle well if you let them.
function renderChart() {
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, chartWidth, chartHeight);
// Find max value for scaling
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;
// 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();
}
}
Speed wasn’t the issue any more; unbounded work was. As playback advances the number of segments grows without limit, and a fast renderer multiplied by infinity is still trouble.
Decimation: Respect the Pixel Budget
A 1,000px-wide canvas can’t display 2,000 distinct x positions. If two data points land in the same pixel column, one of them is wasted work. The screen is the constraint, not the data.
I decimated the dataset so the renderer only ever draws ~800 points per language:
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.strokeStyle = LANGUAGE_COLORS[lang];
ctx.beginPath();
let firstPoint = true;
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
if (step > 1) {
const val = DATA[currentIndex].languages[lang]?.code || 0;
const x = PADDING_LEFT + plotWidth;
const y = plotHeight - (val / maxValue) * plotHeight + PADDING_TOP;
ctx.lineTo(x, y);
}
ctx.stroke();
}
}
That “always include the current point” line stops the right edge from jittering as the sampling window shifts. Without it the leading edge snaps between sample points during playback, which is distracting in exactly the place you’re watching.
High-DPI and Frame Rate
Two more problems showed up once the renderer was fast enough for me to notice details.
High-DPI canvases are blurry if you render at CSS size. The fix is to scale the canvas buffer by devicePixelRatio and draw in CSS coordinates:
const dpr = window.devicePixelRatio || 1;
const rect = chartCanvas.getBoundingClientRect();
chartCanvas.width = rect.width * dpr;
chartCanvas.height = rect.height * dpr;
chartCtx.scale(dpr, dpr);
And 60fps is wasted work here. The data changes at most 30 times a second, so rendering faster burns CPU on frames nobody sees. I let requestAnimationFrame drive the timing and made the loop respect the frame budget instead of racing ahead. The animation stopped heating the laptop when left running.
Draw Order: Keep Small Lines Visible
When 16 lines overlap, whatever gets drawn last wins, and in this dataset that was usually JavaScript.
I sort languages by their current value and draw from smallest to largest, so the larger lines sit behind and the smaller ones stay visible:
const langValues = ALL_LANGUAGES
.map(lang => ({
lang,
color: LANGUAGE_COLORS[lang],
currentValue: getMetricValue(DATA[currentIndex].languages[lang], currentMetric)
}))
.sort((a, b) => a.currentValue - b.currentValue);
for (const { lang, color } of langValues) {
ctx.strokeStyle = color;
// ... draw line
}
This isn’t a rendering feature so much as a decision about what the chart is for. The whole point is watching relative change over time, and burying minority languages defeats that. A language that appears in commit 50 and grows steadily should still be visible at commit 2,000, even if JavaScript has ten times the lines.
What Changed in Practice
With Chart.js I measured ~10–18fps on large repositories (1,000–2,000 commits). With the custom renderer and decimation, playback holds near 60fps on the same data, and I cap it at 30fps to keep CPU use down.
The numbers matter less than the shape of the cost. Rendering is now bounded by screen resolution rather than history length, so a 500-commit repository and a 5,000-commit repository render at the same speed once decimation kicks in.
The model that finally held: the chart is a playback surface, not a reporting dashboard, and playback surfaces have frame budgets. Chart.js is still the right tool for a static chart. For streaming animation over a growing dataset, I had to own the render loop.
See also: Building a Code Evolution Analyzer in a Weekend — the full project story
See also: Deep Dive: Audio Sonification — the sound design experiment