Course outline
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.
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
Skip the transfer list
In the example, change
[offscreen]in thepostMessagecall to[]. The page throwsUncaught DataCloneError: Failed to execute 'postMessage' on 'Worker': An OffscreenCanvas could not be cloned because it was not transferred.and nothing is drawn. - 2
Forward a second drag
In
worker.onmessage, addif (String(event.data).startsWith("drag of 100")) worker.postMessage({ type: "drag", dx: -40 });. After the first drag the worker printsdrag 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 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.
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 radBreak 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).
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
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
How it works. The page calls
canvas.transferControlToOffscreen()and sends the result to a worker in thepostMessagetransfer list. Ownership moves: the page can no longer draw into that canvas (the example prints the realInvalidStateError). In the worker,new THREE.WebGLRenderer({ canvas })works as usual. - 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 nodevicePixelRatio), and pointer movement. The example forwards a drag and the worker turns the cube. - 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 setscanvas.style, which anOffscreenCanvasdoes not have: passfalseas the third argument.TextureLoaderneeds anImage, so useImageBitmapLoader(GLTFLoaderalready does when it can).OrbitControlswants a DOM element, so proxy its events, or move the camera yourself from messages. - 5
Support and the preview.
OffscreenCanvaswith 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 andimport(). In your project Vite buildsnew Worker(new URL("./render-worker.js", import.meta.url), { type: "module" })for you, and the file starts with a normalimport * as THREE from "three". - 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
What must stay on the main thread when three.js runs in a worker?
Why can an OffscreenCanvas not make a GPU-bound scene faster?
Practice
Write the page code that sends the worker a resize message when the container changes, with the size in CSS pixels.
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