Optimising HTML5 Canvas Rendering: Best Practices and Techniques
This blog post is based on a talk by Mana Peirov, Senior Engineer for AG Charts:
Introduction
The HTML5 Canvas element allows developers to draw 2D shapes, images, and text directly in the browser. At AG Grid, we use the Canvas to render charts in AG Charts, our JavaScript Charting Library. We initially built AG Charts to power the Integrated Charts feature in AG Grid (our React Table library). Given that AG Grid is often used for large (100,000+) data sets, we knew we'd have to build a solution that could handle a large amount of data, without compromising performance.
Like most charting libraries, we faced a choice between SVG and Canvas. We chose Canvas because rendering hundreds of thousands of SVG elements would degrade performance, whereas the Canvas allows for more efficient drawing of shapes and lines. Despite this, Canvas isn't a silver bullet and still comes with its own performance challenges. This blog explores some of the techniques we applied when building AG Charts to get the best performance possible out of the Canvas.
HTML5 Canvas Overview
Before we dive into specific optimizations, let's take a look at how the Canvas works. If you're already familiar with how it works, you can skip this bit.
To draw on a <canvas>
, you need to access its rendering context. The most common context is 2D (getContext("2d")
), but Canvas also supports WebGL for 3D rendering. We don't use 3D rendering in AG Charts, so this blog is focused on the 2D rendering context.
Next, after accessing the 2D context, you can use various methods to draw shapes, lines, arcs, images and text. For example:
beginPath()
,moveTo()
, andlineTo()
for lines.arc()
for circles.fillText()
andstrokeText()
for text.
After calling these methods to draw on the canvas, you can then manage its state with methods like save()
and restore()
which let you save and revert to a previous state. This is useful for handling transformations and styles without affecting subsequent drawings.
Finally, the Canvas also provides methods to help with image manipulation, such as drawImage()
to render and manipulate images, ideal for tasks like cropping, resizing, or applying effects. This will be important later on.
Bringing this together, a simple canvas example might look something like this:
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
// Save the current state
ctx.save();
ctx.fillStyle = "green";
ctx.fillRect(10, 10, 100, 100);
// Restore to the state saved by the most recent call to save()
ctx.restore();
ctx.fillRect(150, 40, 100, 100);
which would produce an image like this:
Canvas Rendering Challenges
In the real world, Canvas rendering involves lots of repeated calls to methods like beginPath
, arc
, fill
, and stroke
, as well as frequent use of save
and restore
commands. These commands are simple to use, but when called thousands (or hundreds of thousands) of times per frame, this can significantly impact performance. Given that these methods run on the main thread, any performance issues here are painfully visible to the user and can quickly become a bottleneck as data scales up.
To put this into context, imagine we want to plot n
number of data points to create a chart like this:
A simple way to do this is to loop over each data point, figure out its location, and then draw it onto the Canvas:
export const drawSimple = ({ ctx, data, size, fill, stroke }) => {
const r = size / 2;
data.forEach(({ x, y }) => {
// save context
ctx.save();
// draw circle
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
// apply styles - canvas state change
ctx.fillStyle = fill;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = stroke;
ctx.stroke();
// restore context
ctx.restore();
});
};
Doing this means we make n
number of calls to every Canvas API method, resulting in a linear time complexity (O(n)). In other words, the larger our data set, the slower the rendering will be.
Benchmarking the above example in Chrome results in a rendering time of 287.1ms for 100,000 data points:
Thankfully, there are a few things that we can do to help improve this performance...
Batch Rendering
Let's start with Batch Rendering. Simply put, Batch Rendering is an optimization technique where multiple draw calls or rendering operations are grouped together and executed in a single pass.
Using the example above, instead of drawing each point separately we can group them all into a single path, and apply fill
and stroke
just once, like so:
export const drawBatched = ({ ctx, data, size, fill, stroke }) => {
ctx.save();
const r = size / 2;
ctx.beginPath(); // call once
data.forEach(({ x, y }) => {
ctx.moveTo(x + r, y);
ctx.arc(x, y, r, 0, Math.PI * 2);
});
// apply style state changes
ctx.fillStyle = fill;
ctx.strokeStyle = stroke;
ctx.lineWidth = 2;
// draw path in one batch
ctx.fill();
ctx.stroke();
ctx.restore();
};
Whilst the time complexity of this function is still O(n), because we still need to iterate over the entire data array, the majority of our drawing operations, such as ctx.save()
, ctx.beginPath()
, setting styles, and the final ctx.fill()
/ ctx.stroke()
calls occur outside the loop and are performed once, which takes constant time (O(1)).
This approach can cut rendering times dramatically. Using our example from earlier, implementing batch rendering alone can reduce rendering time down to just 15.4ms for 100,000 data points:
It's important to note that, as with most things in software development, Batch Rendering also comes with a downside: Rendering multiple overlapping shapes can result in an unintended "blob" effect, as all shapes are treated as one large path:
To address this, we can use the Offscreen Canvas API...
Using Offscreen Canvas API to render sprites
Unlike the Canvas API, the OffscreenCanvas
interface provides a canvas that can be rendered off-screen, decoupling the DOM and the Canvas API so that the <canvas>
element is no longer entirely dependent on the DOM. Rendering operations can also be run inside a worker context, allowing you to run some tasks in a separate thread and avoid heavy work on the main thread.
export const drawOffscreen = ({ canvasCtx, offscreenCanvasCtx, data }) => {
canvasCtx.save();
offscreenCanvasCtx.fillStyle = fill;
offscreenCanvasCtx.strokeStyle = stroke;
offscreenCanvasCtx.lineWidth = strokeWidth;
const center = (size + strokeWidth) / 2;
const r = size / 2;
const sSize = size + strokeWidth;
// call once
offscreenCanvasCtx.beginPath();
offscreenCanvasCtx.arc(center, center, r, 0, Math.PI * 2);
offscreenCanvasCtx.fill();
offscreenCanvasCtx.stroke();
// Draw offscreen canvas content onto main canvas, for each data point
data.forEach(({ x, y }) => {
canvasCtx.drawImage(
offscreenCanvasCtx,
x - center,
y - center,
sSize,
sSize
);
});
canvasCtx.restore();
};
In the code above, we're using an offscreen canvas to create a single point (circle), and then use the drawImage
method to draw it onto the main canvas at various coordinates. Essentially, we're reducing the time complexity of every beginPath
, arc
, fill
and stroke
call to O(1), in favour of O(n) calls to drawImage
, which is a faster way of rendering content onto the canvas. Additionally, much like we did with batch rendering, we now only need to call save
and restore
once as well.
When applied to our example, this technique reduces the rendering time for 100,000 data points down to 65.7ms:
Crucially, this technique does not have any impact on the quality of the output, albeit at a slight performance cost when compared to Batch Rendering:
Change Detection
In another blog post, we discussed how we use a tree-based scene graph to optimise the performance of our canvas rendering in AG Charts. In short, our scene graph consists of nodes representing different chart elements, with a base node class that includes properties for positioning, styling, and rendering. Each node type—whether a line, shape, or marker—inherits from this base class and implements its own rendering logic.
When it comes to updating the chart elements, the naive approach would be to redraw everything, even the nodes that aren't affected by the change. That means starting at the root node, traversing through the scene graph, top down, re-rendering every group and its children. This involves invoking the canvas render methods and calls to update the state, which can be expensive. To tackle this, we implemented a dirty
flag that gets set to true when certain properties change, such as the fill, stroke, size, or position of a marker. This flag percolates up the tree to the parent nodes, meaning the associated parent group can be marked as 'dirty'. Each of these groups acts as a 'layer', which is essentially a graphic element with a zIndex
. We can then create an Offscreen Canvas for each layer so that it can be rendered independently of other layers. This means that during re-renders, we know which groups are dirty and need to be redrawn, and if they are unchanged, we use the cached bitmap image from the previous render instead. This approach greatly enhances the overall performance of our rendering.
Long story short, when working with the canvas, it's important to minimise the number of redundant drawing operations by tracking state changes and only redrawing what is necessary.
Summary
In the table below, you can clearly see the differences in performance of each approach when drawing 100,000 data points onto the canvas:
Simple | Batched | Offscreen |
---|---|---|
287.1ms | 15.4ms | 66.9ms |
Whilst the batched method is by far the fastest, it comes at the cost of reduced quality when compared to Simple / Offscreen rendering:
As always, there's never a one-size-fits-all solution when it comes to optimisation. It's important to consider your use case and decide where to make the trade-offs, namely in performance vs. quality.
Of course, if you're looking to create beautiful, high-performance charts without doing all this work yourself, you can just use AG Charts instead.
Get started for free today: