This blog demonstrates how to improve canvas animation performance and UI interactivity in React applications by leveraging OffscreenCanvas and Web Workers.
The following GIF demonstrates the difference in animation rendering between a canvas running on the main thread and one rendered offscreen in a Web Worker, when an expensive calculation is triggered on the main UI thread.
As shown, the animation in the main-thread canvas gets 'blocked' when the expensive calculation is triggered, while the OffscreenCanvas continues to render smoothly. This happens because JavaScript is single-threaded by nature—heavy computations on the main thread block rendering and UI interactions, degrading overall interactivity.
If we inspect the Performance tab in DevTools, we can see that the app renders only partial frames when a heavy calculation is triggered on the main thread, and the main-thread canvas animation pauses entirely.
To ensure smooth canvas rendering and responsive UI, we can offload canvas rendering to an OffscreenCanvas running in a Web Worker. This approach provides a noticeable performance boost by freeing up the main thread.
Offload Canvas Rendering to a Web Worker
The basic idea is to first use the transferControlToOffscreen() API to create an OffscreenCanvas and transfer control of the canvas from the main thread. Then, we create a Web Worker and pass the OffscreenCanvas instance to it as a transferable object so rendering can happen in the worker thread.
// convert canvas into offscreen canvas and transfer control
offscreen = offscreenCanvas.transferControlToOffscreen();
// create a new worker thread
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module",
});
// send offscreen canvas as transferable object in payload
worker.postMessage({ canvas: offscreen }, [offscreen]);
Then in the worker thread, we retrieve the OffscreenCanvas instance from the message payload and render on it just like we normally would.
self.onmessage = (e: MessageEvent) => {
// get canvas from msg payload
const canvas = e.data.canvas as OffscreenCanvas;
// render canvas
render(canvas);
};
If we look at the same snapshots in DevTools, we can see that while the main-thread animation pauses, the OffscreenCanvas running in the worker thread continues to render smoothly.
Integrate with React
You might run into bugs when trying to render the OffscreenCanvas on component mount during development. This happens because useEffect runs twice in React’s strict mode, and you can't transfer control of the same canvas more than once.
Since there's no native API to cancel a control transfer, using the cleanup function in useEffect doesn't help. To work around this, you’ll need to manually track whether control has already been transferred—typically using a ref.
The general integration code snippet is shown below, and the full demo code is available on StackBlitz.
import React from 'react';
function App() {
const offscreenCanvasRef = React.useRef<HTMLCanvasElement>(null);
const inCanvasControlTransferredRef = React.useRef<boolean>(false);
React.useEffect(() => {
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module",
});
let offscreenCanvas = offscreenCanvasRef.current;
let offscreen;
if (!inCanvasControlTransferredRef.current) {
if (!offscreenCanvas) return;
offscreenCanvas.width = 300;
offscreenCanvas.height = 300;
// Transfer the OffscreenCanvas to the worker
offscreen = offscreenCanvas.transferControlToOffscreen();
inCanvasControlTransferredRef.current = true;
}
if (offscreen) {
worker.postMessage({ canvas: offscreen }, [offscreen]);
}
}, [])
return <canvas ref={offscreenCanvasRef} />
}