JavaScript OffscreenCanvas Table
| Piece | What it does | Field note |
|---|---|---|
transferControlToOffscreen() | The live door | Existing canvas becomes worker-rendered - DOM keeps a placeholder, ownership transfers one-way |
new OffscreenCanvas(w,h) | The headless door | Buffer nobody watches - measurement, processing, thumbnails; eyes only via ImageBitmap |
postMessage transfer | The move | The transfer list [off] is mandatory - without it ownership never leaves the main thread |
transferToImageBitmap | The export | Zero-copy snapshot of the buffer - the bridge from compute to visible canvas via drawImage |
commit() | The frame push | Worker-driven surfaces push on commit - a loop assuming rAF auto-commit stalls silently |
no rAF in worker | The loop reality | No animation frame API worker-side - setTimeout cadence or render-on-input; budget accordingly |
inputs by hand | The architecture cost | No DOM, no events - main thread forwards pointer/resize as data; hit-testing lives worker-side |
webgl2 off-thread | The heavy winner | Both 2D and WebGL contexts work - image processing and particle scenes gain the most headroom |
OffscreenCanvas detaches the canvas from the DOM: the same 2D and WebGL drawing APIs, running on a buffer that lives wherever you move it - including inside a Worker with no document at all. That split is the fix for the web's oldest graphics complaint: heavy canvas work (particle systems, image processing, video filters) no longer competes with layout, scrolling and input on the main thread, because rendering happens where the jank does not.
Bottom line: there are two doors and one bridge. transferControlToOffscreen() hands an existing <canvas>'s pixels to an OffscreenCanvas you then postMessage to the worker (transferring, not copying - the DOM keeps a placeholder); new OffscreenCanvas(w, h) creates a standalone buffer nobody sees until you draw it into a visible canvas or hand its ImageBitmap across. The bridge back to eyes is commit (workers) or the placeholder auto-sync (main thread), and the transfer semantics are one-way: a canvas sent to the worker is the worker's to render, forever.
The honest part: going off-thread buys frame budget and costs immediacy - the worker canvas cannot read the DOM, receive input events, or touch document state, so architectures reorganize around message-passing (input events forwarded as data, sizes communicated explicitly on resize). And the payoff is workload-dependent: a few rects per frame gain nothing and gain a worker's latency; image processing, large particle counts, WebGL scenes and anything hitting ~16ms ceilings gain exactly the headroom the main thread stopped having.
How to use
- Hand a live canvas to a worker: const off = canvas.transferControlToOffscreen(); worker.postMessage({canvas: off}, [off]) - the transfer list is mandatory (it is how ownership moves), and after this the main thread only owns the placeholder box; all drawing code lives worker-side.
- Or create a buffer nobody displays: new OffscreenCanvas(width, height) works on main thread or worker for compute-only rendering - process an image, measure text, generate a thumbnail - then transfer its bitmap: const bmp = off.getContext('2d').canvas.transferToImageBitmap().
- Move pixels across threads cheaply: postMessage({bmp}, [bmp]) transfers an ImageBitmap zero-copy - the pattern for 'render in worker, display on main': draw the bitmap into a visible canvas via drawImage in one main-thread pass per frame.
- Forward inputs as data: the worker canvas receives no events - the main thread listens (pointer, wheel, resize) and posts {type, x, y} messages; keep a size sync on resize (off.width = msg.w) because the worker cannot measure its own placeholder.
- Pick contexts honestly: 2D and WebGL both work off-thread ('webgl2' included), but the worker has no requestAnimationFrame - use your own rAF-less loop driven by setTimeout granularity, or render on demand when input arrives; commit() pushes frames when the canvas is controlled from worker-side code.
Frequently asked questions
When is OffscreenCanvas actually worth the architecture?
When the frame budget is the bottleneck. The wins are consistent: image/video processing (filters, thumbnails, decoding large images) that currently blocks scrolling; particle or physics-heavy 2D scenes pushing past 16ms; WebGL scenes whose per-frame CPU prep (matrix math, buffer uploads) starves input handling; and any render loop that must keep running when the main thread is busy - workers keep rendering through main-thread jank that would freeze a DOM canvas. The losses: trivial drawing (a chart, a signature pad) gains nothing but gains message-passing latency and a second code location. The honest threshold: if your render loop currently drops frames on interactions, OffscreenCanvas is the fix; if it never gets near 16ms, keep the canvas where the events are.
What is the difference between transferControlToOffscreen and new OffscreenCanvas?
transferControlToOffscreen converts an EXISTING <canvas> in the document into a worker-rendered surface: the DOM element remains as a placeholder, its pixels stream to the page automatically, and the OffscreenCanvas it returns must be transferred to the worker (once, one-way) - this is the door for 'my visible canvas, rendered off-thread'. new OffscreenCanvas(w, h) creates a headless buffer with no DOM presence - the door for 'pixels I need but nobody watches': text measurement, image processing, thumbnail generation. Its output reaches eyes only when you extract it (transferToImageBitmap) and draw it somewhere visible. First door: continuous display, worker-owned. Second door: compute, main-thread-owned until you ship the bitmap.
Why does my worker canvas never update on screen?
Three usual suspects. Ownership never transferred: postMessage({canvas: off}) without the transfer list ([off] as the second argument) clones-fails or leaves the buffer main-thread-bound - the structured-clone algorithm requires the explicit transfer for OffscreenCanvas. No commit: a worker-side canvas driven by transferControlToOffscreen pushes frames only when its context's commit() is called (or implicitly per task in some engines) - a render loop that assumes rAF-auto-commit stalls silently. Or the placeholder was removed: the DOM <canvas> element is the display pipe; hiding or unmounting it while the worker renders is rendering into a void. The debug order: transfer list, commit call, placeholder presence - in that order, because all three fail silently.
How does input handling work when rendering lives in a worker?
By hand. The worker canvas is not in the document, so it receives no pointer, keyboard or resize events - the main thread keeps the listener job and forwards normalized data ({type: 'pointermove', x, y, buttons}) via postMessage. This split is the architecture's real cost and its real discipline: input latency gains one hop (usually sub-millisecond, but real), hit-testing math must live worker-side (or be mirrored), and resize means an explicit message pair (new dimensions, then the worker reallocates its buffer) instead of reading clientWidth. Teams that keep the main thread as the input authority and the worker as the frame factory - sharing a single command schema - report the pattern stabilizes in days; teams that try to mirror DOM state into the worker fight the design forever.