Tutorial · Intermediate · ~45 min
Offscreen Canvas + Web Workers
OffscreenCanvas transfers full WebGL rendering to a
dedicated Web Worker, so long simulation frames never block the UI
thread. The main thread handles DOM events and passes them to the worker
via postMessage. The worker runs the render loop and
directly presents to the GPU without round-trips through the main
thread.
1Transfer the canvas to a worker
// main.js — runs on the main thread const canvas =
document.getElementById('sim-canvas'); if
(!canvas.transferControlToOffscreen) { console.warn('OffscreenCanvas
not supported — falling back to main thread'); // fallback: run
Three.js here directly } else { const offscreen =
canvas.transferControlToOffscreen(); const worker = new
Worker('./render-worker.js', { type: 'module' }); worker.postMessage({
type: 'init', canvas: offscreen, width: canvas.clientWidth, height:
canvas.clientHeight, pixelRatio: devicePixelRatio, }, [offscreen]); //
← transfer ownership, not copy }
Transfer ownership with the second
postMessage argument. After this the main thread can no
longer use the canvas element's drawing context — the worker owns it
exclusively.
2Set up Three.js inside the worker
// render-worker.js — runs on a worker thread import * as THREE from
'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js'; let
renderer, scene, camera; self.addEventListener('message', e => { const
{ type } = e.data; if (type === 'init') { const { canvas, width,
height, pixelRatio } = e.data; renderer = new THREE.WebGLRenderer({
canvas, antialias: true }); renderer.setPixelRatio(pixelRatio);
renderer.setSize(width, height, false); // false = don't set style
scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera(60,
width / height, 0.1, 1000); camera.position.z = 5; scene.add(new
THREE.AmbientLight(0xffffff, 0.6)); const mesh = new THREE.Mesh( new
THREE.TorusKnotGeometry(1, 0.3, 128, 16), new
THREE.MeshStandardMaterial({ color: 0x22c55e }) ); scene.add(mesh); //
Worker-side render loop let last = 0; function loop(t) {
mesh.rotation.y = t * 0.001; renderer.render(scene, camera);
self.requestAnimationFrame(loop); // works in workers! }
self.requestAnimationFrame(loop); } });
3Proxy DOM events to the worker
// main.js — forward pointer and keyboard events const worker = /* ...
*/; function forwardEvent(type, e) { worker.postMessage({ type, x:
e.clientX, y: e.clientY, dx: e.movementX, dy: e.movementY, button:
e.button, key: e.key, }); } canvas.addEventListener('pointermove', e
=> forwardEvent('pointermove', e));
canvas.addEventListener('pointerdown', e =>
forwardEvent('pointerdown', e)); canvas.addEventListener('pointerup',
e => forwardEvent('pointerup', e)); canvas.addEventListener('wheel', e
=> { worker.postMessage({ type: 'wheel', deltaY: e.deltaY });
e.preventDefault(); }, { passive: false });
// render-worker.js — handle forwarded events let isDragging = false,
lastX = 0, lastY = 0; self.addEventListener('message', e => { switch
(e.data.type) { case 'pointermove': if (!isDragging) break;
camera.position.x += e.data.dx * 0.005; camera.position.y -= e.data.dy
* 0.005; break; case 'pointerdown': isDragging = true; break; case
'pointerup': isDragging = false; break; case 'wheel':
camera.position.z += e.data.deltaY * 0.01; break; } });
4Resize handling across threads
// main.js — observe canvas size and notify worker const
resizeObserver = new ResizeObserver(entries => { const {
contentBoxSize } = entries[0]; const w = contentBoxSize[0].inlineSize;
const h = contentBoxSize[0].blockSize; worker.postMessage({ type:
'resize', width: w, height: h }); }); resizeObserver.observe(canvas);
// render-worker.js — apply resize to renderer and camera if
(e.data.type === 'resize') { const { width, height } = e.data;
renderer.setSize(width, height, false); camera.aspect = width /
height; camera.updateProjectionMatrix(); }
Never call
canvas.width or
canvas.height setters from the worker — use
renderer.setSize which sets the internal drawing buffer
dimensions without touching the DOM.
5Using Comlink for cleaner APIs
// Comlink wraps workers in a Proxy so you can call methods directly
import * as Comlink from
'https://cdn.jsdelivr.net/npm/comlink@4/dist/esm/comlink.mjs'; //
render-worker.js const api = { init(canvas, w, h, dpr) { /* ... */ },
setColor(hex) { mesh.material.color.setHex(hex); }, exportFrame() {
return renderer.domElement.toDataURL(); }, // OffscreenCanvas only };
Comlink.expose(api); // main.js const worker = new
Worker('./render-worker.js', { type: 'module' }); const remoteRenderer
= Comlink.wrap(worker); const offscreen =
canvas.transferControlToOffscreen(); await
remoteRenderer.init(Comlink.transfer(offscreen, [offscreen]),
innerWidth, innerHeight, devicePixelRatio); // Now call methods as if
local:
document.getElementById('color-picker').addEventListener('input', e =>
{ remoteRenderer.setColor(parseInt(e.target.value.slice(1), 16)); });
Comlink handles the
postMessage /
addEventListener boilerplate for you, exposing a clean
async RPC interface. Transferable objects (like
OffscreenCanvas) still need explicit
Comlink.transfer() wrapping.