DEV Community

Alex MacArthur
Alex MacArthur

Posted on • Originally published at macarthur.me on

Consider Animating Your Canvas in a Web Worker

I've been working on a project that renders a lot canvas elements, each with an image and some shapes painted on it. They're painted lazily using the IntersectionObserver API, but even so, at a certain point, the browser's main thread has really starts to feel it. As those canvases render, other interactions slows down. The jank becomes real.

When I encountered this, I started thinking back to when I first dabbled with web workers and recognized the positive impact they can have on a browser's snappiness. So, naturally, I started to explore whether they'd come in handy for drawing on a canvas.

I wasn't that optimistic. A canvas is a very visual element – inherently related to what the user sees and how the browser paints to the screen. But as it turns out, you can easily paint a canvas inside a web worker – even animate it. And it can make a huge difference to a user's experience on your site.

A Contrived Playground

To explore this, we're starting with a simple setup – a canvas drawn with a heart, which is set to infinitely rotate, as orchestrated by requestAnimationFrame(). I'm leaving the boring details out, but here's generally how it works:

<canvas id="canvas" height="250" width="250"></canvas>

<script>
    const ctx = canvas.getContext('2d');

    function drawHeart() {
        // Draw a heart. 
    }

    function drawAndRotate() {
        // Clear, rotate a smidge.

        drawHeart();

        // Right before the next paint, trigger another redraw & rotation.
        requestAnimationFrame(drawAndRotate);
    }

    drawAndRotate();  
</script>   
Enter fullscreen mode Exit fullscreen mode

The result looks something like this (on a real page, it'd be an infinite rotation).

The Consequences of a Stuffy Thread

This would normally run pretty smoothly – until we start messing with the main thread. Let's do that artificially with a long-running while loop:

<button id="button">Block for Three Seconds</button>
<canvas id="canvas" height="250" width="250"></canvas>

<script>
    document.getElementById('button').addEventListener('click', () => {
        const start = Date.now();

        // Thread is blocked! No UI updates allowed.
        while (Date.now() - start < 3000) {}
    });  

    // ...our canvas-painting code from before.
</script>   
Enter fullscreen mode Exit fullscreen mode

While this button is pushed, everything running on the main thread is blocked for three seconds – including UI updates, event listeners, and anything else running on it. An animated canvas is no exception.

See for yourself what happens to the animation when the main thread is blocked. Complete freeze.

See the Pen Animated Canvas - Main Thread by Alex MacArthur (@alexmacarthur) on CodePen.

Of course, the impact of a clogged main thread will vary between applications, but there's usually a lot going on during a browser session, and all of that activity is competing with animations you'd probably like to keep buttery smooth. It's generally a good idea to offload as much of that work as reasonably as possible.

Let a Web Worker Do the Heavy Lifting

Within the last few years, browsers introduced the OffscreenCanvas interface, which allows you render and manipulate a canvas in a separate thread, completely detached from the DOM. This means all of that expensive drawing – and even animation driven by requestAnimationFrame() – can be done off the main thread, without impacting the front-end responsiveness of an application.

Let's tweak our setup a bit. Rather than handling the entire animation in a single script, we'll do the following:

  • Create an off-screen instance from our canvas element for painting.
  • Transfer ownership of that instance to a web worker.
  • Render and animate the canvas remotely , from within the worker.

Here's how our primary script will look:

<canvas id="canvas" height="250" width="250"></canvas>

<script>
    const canvas = document.getElementById('canvas');
    // Create a canvas instance not dependent on the DOM.
    const offscreenCanvas = canvas.transferControlToOffscreen();

    // Register our worker script.
    const worker = new Worker('canvas-worker.js');

    // Give ownership of the canvas to the worker's context.
    worker.postMessage({canvas: offscreenCanvas}, [offscreenCanvas]);

    // ...other code blocking main thread for three seconds.
</script>   
Enter fullscreen mode Exit fullscreen mode

And here's that new canvas-worker.js file. The code is largely the same, but waiting to receive a reference to the offscreen canvas from the main thread.

// canvas-worker.js

self.onmessage = function (event) {
    const canvas = event.data.canvas;
    const ctx = canvas.getContext('2d');

    function drawHeart() {
        // Draw a heart. 
    }

    function drawAndRotate() {
        // Clear, rotate a smidge.

        drawHeart();

        // Right before the next paint, trigger another redraw & rotation.
        requestAnimationFrame(drawAndRotate);
    }

    drawAndRotate(); 
};
Enter fullscreen mode Exit fullscreen mode

With this change, everything looks like it did before. The heart is drawn and it infinitely rotates. But if you click the button to block the main thread this time around, the animation won't stop. Every bit of its rendering and animation is controlled by a separate thread, safe and out of the way.

Try hitting that button again to block the thread. No more freezing.

See the Pen Animated Canvas - Main Thread by Alex MacArthur (@alexmacarthur) on CodePen.

The particularly interesting part of this for me is the fact that I can use requestAnimationFrame() apart from the DOM with no issue. It's a little amazing that we can give that level of control for a UI-related entity to a completely separate thread and manipulate it remotely, with so little setup.

Workerize Everything?

Meh, probably not. Depending on the complexity of your canvas (maybe you're not even animating it), it just might not be worth the overhead of wiring up a worker to render it. Not to mention, there's a performance cost to coordinating that communication between threads anyway. In some cases, you might even shoot yourself in the foot by leaning into it all too hard.

Even so, I'd like to see us begin to ask "would this be better suited for a worker?" more often than we do now. Chances are, we're leaving a lot of performance gains on the table by not doing so.

Top comments (0)