Command Palette

Search for a command to run...

Hectal
Course outline
Topic 17.6Advanced ~18 min · step 6 of 7

Workers and OffscreenCanvas

In one line

An OffscreenCanvas lets a Web Worker own the canvas and run three.js there, so heavy JavaScript no longer blocks the page's buttons. What moves into the worker, and what must stay, is the whole design.

0/7 · 0%

Think of it like this

A restaurant where the waiter and the cook are the same person. While the cook is busy, nobody takes orders. Hire a second person for the kitchen and the waiter is free, but now they must pass notes.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Web Worker
A script that runs on its own thread, with no DOM, talking to the page through messages.
OffscreenCanvas
A canvas not tied to the page; a worker can draw into one and the pixels show on a page canvas.
transferable
An object (like a canvas or an ArrayBuffer) whose ownership is moved to the worker instead of copied.
main thread
The thread that runs your page's JavaScript, layout and input.
postMessage
The call that sends data between the page and a worker.

Step by step

01Check support

The example first prints whether OffscreenCanvas exists. If not, fall back to the normal canvas.

02Hand the canvas over

transferControlToOffscreen() then worker.postMessage(msg, [offscreen]). The second argument is the transfer list: without it the call throws a DataCloneError.

03Draw in the worker

The worker imports three.js, makes a renderer on the transferred canvas, draws a frame and reports renderer.info. Note setSize(w, h, false).

04Forward the input

Events happen on the page, so the page sends the movement and the worker turns the cube. A pretend drag of 100 px keeps the output the same every run.

Try it yourself

  1. 1

    Skip the transfer list

    In the example, change [offscreen] in the postMessage call to []. The page throws Uncaught DataCloneError: Failed to execute 'postMessage' on 'Worker': An OffscreenCanvas could not be cloned because it was not transferred. and nothing is drawn.

  2. 2

    Forward a second drag

    In worker.onmessage, add if (String(event.data).startsWith("drag of 100")) worker.postMessage({ type: "drag", dx: -40 });. After the first drag the worker prints drag of -40 px turned the cube by -0.20 rad: input is just messages. (Send it after the first frame, not at a fixed time: a drag that arrives before the scene exists throws inside the worker.)

Code & diagrams

A cube drawn by a worker New tab

A classic blob worker with import(), because this page's preview does not run module workers from blobs. The status bar belongs to the page's own context, so its counts stay empty for the worker's renderer.

idle

Console

Run the scene to see its console.

Expected console output

OffscreenCanvas available: true
page: InvalidStateError: Failed to execute 'getContext' on 'HTMLCanvasElement': Cannot get context from a canvas that has transferred its control to offscreen.
worker: setSize(w, h) failed in a worker: Cannot set properties of undefined (setting 'width')
worker: first frame drawn in the worker: 1 draw call, 12 triangles
worker: drag of 100 px turned the cube by 0.50 rad

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Draw on the page after the transfer

After transferControlToOffscreen(), call canvas.getContext("webgl2") on the page (the example already does).

terminal
$ Run
── what you'll see ──
page: InvalidStateError: Failed to execute 'getContext' on 'HTMLCanvasElement': Cannot get context from a canvas that has transferred its control to offscreen.
This is the line the example prints.

Myth vs fact

Myth

A worker makes the 3D scene render faster.

Fact

The GPU does the same work. A worker only frees the page's thread, so buttons and scrolling stay smooth when JavaScript is the bottleneck.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    For big data (vertex arrays, textures) put the buffer in the transfer list: worker.postMessage({ positions }, [positions.buffer]). It moves in constant time and the page's copy becomes empty. Without it the data is copied, which can cost more than the render you saved.

Remember this

  1. 1

    The problem this solves: the main thread runs your page: clicks, scrolling, layout and your render loop. A heavy loop (animation maths, physics, big scene updates) makes buttons feel stuck. A worker is another thread. If it owns the canvas and runs three.js, the page's buttons stay responsive.

  2. 2

    How it works. The page calls canvas.transferControlToOffscreen() and sends the result to a worker in the postMessage transfer list. Ownership moves: the page can no longer draw into that canvas (the example prints the real InvalidStateError). In the worker, new THREE.WebGLRenderer({ canvas }) works as usual.

  3. 3

    What stays on the page. Everything with the DOM: pointer and keyboard events, the controls' event handling, document, CSS, ResizeObserver, the panel and HTML overlays. The page forwards what the worker needs as messages: size, pixel ratio (a worker has no devicePixelRatio), and pointer movement. The example forwards a drag and the worker turns the cube.

  4. 4

    Edge cases you will meet. In r186, renderer.setSize(w, h) throws in a worker ("Cannot set properties of undefined (setting 'width')"), because it sets canvas.style, which an OffscreenCanvas does not have: pass false as the third argument. TextureLoader needs an Image, so use ImageBitmapLoader (GLTFLoader already does when it can). OrbitControls wants a DOM element, so proxy its events, or move the camera yourself from messages.

  5. 5

    Support and the preview. OffscreenCanvas with WebGL 2 works in current Chrome, Edge, Firefox and Safari: check caniuse for your audience. This page's sandbox cannot start a module worker from a blob (it fires an error event), so the live example uses a classic worker and import(). In your project Vite builds new Worker(new URL("./render-worker.js", import.meta.url), { type: "module" }) for you, and the file starts with a normal import * as THREE from "three".

  6. 6

    When it helps, and when it does not. It moves JavaScript and render() CPU work off the page thread. It does not make the GPU faster: a GPU-bound scene is just as slow. Messages are copied (use transferables for big arrays), and every input has a small delay. It also doubles the places a bug can hide. Use it when the main thread is the bottleneck: measure first (Topic 17.1).

Explain it without notes

01

What must stay on the main thread when three.js runs in a worker?

02

Why can an OffscreenCanvas not make a GPU-bound scene faster?

Practice

01

Write the page code that sends the worker a resize message when the container changes, with the size in CSS pixels.

02

Name two things that stop working when the renderer moves to a worker, and what replaces them.

Done when you can

  • Hand a canvas to a worker and draw there

  • Say what stays on the page, and why

  • Name two things that change when three.js runs in a worker

  • Say when a worker will not help