Tutorial · Intermediate · ~45 min
OffscreenCanvas · Web Workers · Three.js · Comlink

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.