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); } });
// 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.
Frequently Asked Questions
What will I learn in this tutorial?
Move Three.js and WebGL rendering off the main thread with OffscreenCanvas and a dedicated Web Worker, keeping the UI responsive at all times.
What topics are covered in this tutorial?
This tutorial covers: Transfer the canvas to a worker, Set up Three.js inside the worker, Proxy DOM events to the worker, Resize handling across threads, Using Comlink for cleaner APIs.
What tools and technologies does this tutorial use?
This tutorial uses OffscreenCanvas, Web Workers, Three.js, Comlink.
How long does this tutorial take?
This tutorial takes approximately 45 minutes to complete.
What prerequisites do I need before starting?
This is a Intermediate-level tutorial — no special preparation beyond basic JavaScript is assumed.