Building a candlestick chart engine on raw Canvas2D (no chart library)

# frontend# javascript# performance
Building a candlestick chart engine on raw Canvas2D (no chart library)Professor Hachikyan

Every charting tutorial reaches for a library. I ended up hand-writing the engine instead, and it...

Every charting tutorial reaches for a library. I ended up hand-writing the engine
instead, and it turned out to be less code than I expected — plus three performance
lessons I hadn't anticipated.

This is what I learned plotting candlesticks on raw Canvas2D, with the naive version
first so you can see exactly what goes wrong.

The naive version

The obvious approach is to redraw everything on every frame:

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  for (const candle of visible) drawCandle(ctx, candle);
  for (const obj of drawings) drawDrawing(ctx, obj);
  requestAnimationFrame(draw);
}
Enter fullscreen mode Exit fullscreen mode

It works. It's also redrawing 1500 candles, 3 overlays and every drawing object
60 times a second, while nothing has changed.

Lesson 1: only redraw when something changed

Live crypto data doesn't tick at 60Hz. It ticks maybe a few times a second, and
usually only the last candle changes. So separate the render from the loop:

let dirty = true;

function markDirty() { dirty = true; }

function frame() {
  if (dirty) {
    render();
    dirty = false;
  }
  requestAnimationFrame(frame);
}

// data arrives
ws.onmessage = (msg) => {
  updateLastCandle(msg);
  markDirty();
};

// user pans or zooms
canvas.addEventListener('wheel', (e) => {
  zoom(e.deltaY);
  markDirty();
});
Enter fullscreen mode Exit fullscreen mode

Panning and zooming do change everything, so those set the flag. Price ticks
usually don't. That one change took my idle CPU from ~15% to near zero.

Lesson 2: cache what doesn't move

The background grid, axis labels and the historical candles (everything except the
last one) are static between data ticks. Draw them once into an offscreen canvas,
then blit:

const back = document.createElement('canvas');

function rebuildBackBuffer() {
  back.width = canvas.width;
  back.height = canvas.height;
  const bctx = back.getContext('2d');
  drawGrid(bctx);
  drawAxes(bctx);
  drawCandles(bctx, candles.slice(0, -1));   // everything but the live candle
}

function render() {
  ctx.drawImage(back, 0, 0);                 // one call instead of thousands
  drawCandle(ctx, candles[candles.length - 1]);  // just the live one
  drawings.forEach((o) => drawDrawing(ctx, o));
}
Enter fullscreen mode Exit fullscreen mode

Rebuild the back buffer only on pan/zoom/timeframe change. On a price tick you're
now drawing one candle, not 1500.

Lesson 3: indicators as pure functions over typed arrays

Indicator code gets tangled when it reads from the chart's data model. Keep it pure —
take arrays in, return arrays out:

function sma(values, period) {
  const out = new Float64Array(values.length).fill(NaN);
  let sum = 0;
  for (let i = 0; i < values.length; i++) {
    sum += values[i];
    if (i >= period) sum -= values[i - period];
    if (i >= period - 1) out[i] = sum / period;
  }
  return out;
}

function ema(values, period) {
  const out = new Float64Array(values.length);
  const k = 2 / (period + 1);
  out[0] = values[0];
  for (let i = 1; i < values.length; i++) {
    out[i] = values[i] * k + out[i - 1] * (1 - k);
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode

Typed arrays matter here — with 1500 bars and 38 indicators recomputed on every
timeframe switch, Float64Array vs a plain Array was a measurable difference,
and it keeps the GC quiet.

Because they're pure, they're also trivially testable:

test('sma of a constant series is that constant', () => {
  const out = sma([5, 5, 5, 5, 5], 3);
  assert.equal(out[4], 5);
});
Enter fullscreen mode Exit fullscreen mode

Lesson 4: hit-testing drawing objects in chart space

Drawing tools (trend lines, Fibonacci, rectangles) have to survive pan and zoom.
The trick is to do hit-testing in chart coordinates, not pixels — convert the
mouse position once, then everything is plain geometry:

function toChartCoords(mouseX, mouseY) {
  return {
    x: viewStartIndex + mouseX / candleWidth,
    y: priceMin + (canvas.height - mouseY) / canvas.height * (priceMax - priceMin),
  };
}

function hitTest(line, chartPoint, toleranceInBars) {
  // distance from point to segment, in chart units
  const d = distanceToSegment(chartPoint, line.a, line.b);
  return d < toleranceInBars;
}
Enter fullscreen mode Exit fullscreen mode

Because the objects live in chart space, zooming doesn't move them relative to the
data — which is exactly what a trader expects a trend line to do.

The one trading thing that surprised me

I built this to put Binance, Bybit and OKX on one chart, mostly to see the spread
between them. What I didn't expect: the absolute spread is boring (a few basis
points on BTC), but the first tick where it jumps is informative — the venue
spread usually widens before the candles react.

That's a property of the data, not the canvas, but it's the reason the project
exists at all.

Takeaway

You don't need a chart library for a charting app. You need:

  1. a dirty flag, so you render on change rather than on frame,
  2. an offscreen back buffer for everything that didn't change,
  3. pure indicator functions over typed arrays,
  4. drawing objects in chart space.

That's the whole engine.

If you want to see the result (38 indicators, 3 exchanges, one canvas, no signup):
cryptota.duckdns.org. Happy to go deeper on any
of the four pieces in the comments.