Topic 12.5
Workers and OffscreenCanvas
In one line
Your script, layout and painting share one thread, and a long loop freezes all three. A Web Worker runs code on another thread, and OffscreenCanvas lets that thread draw. You learn when this helps, how it fails and when it is not worth it.
Think of it like this
A restaurant with one waiter who is also the cook. While he chops vegetables for ten minutes, nobody gets water, orders or bills. Hire a cook (a worker) and he chops in the kitchen while the waiter keeps serving. They talk only by passing notes through a hatch (messages), because they share no pan.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- long task
- Work on the main thread longer than about 50 milliseconds. The page cannot respond meanwhile.
- Web Worker
- A script that runs on a separate thread and talks to the page by messages.
- postMessage
- The call that sends a message (a copy of some data) to or from a worker.
- transfer
- Moving ownership of a large object to a worker without copying it. The sender loses it.
Step by step
01Freeze the page on purpose
The first example makes a 400,000-row CSV in memory and a ticker that counts every 4 milliseconds. Press Parse on the main thread: the ticker stops until the parse ends. The code logs 0 ticks during it. Parse time is not logged, because it depends on the machine.
02Move the job to a worker
The same parse function, as text, runs in new Worker(URL.createObjectURL(new Blob([text]))). The page sends the CSV; the worker answers with a count and a total. The ticker keeps moving, and the answer is the same: 400,000 rows, mean 26.50 minutes.
03Let the worker draw
The second example transfers a canvas and the worker draws 200,000 dots.
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ type: "draw", canvas: offscreen }, [offscreen]); // the second argument moves it04Meet the limits
It then logs two real failures: getContext on the transferred canvas, and the worker's relative fetch. A feature check first (typeof OffscreenCanvas === "function") lets it fall back to the main thread on an old browser.
Try it yourself
- 1
A smaller job
In the first example, change
i < 400000toi < 4000. The log says4,000 rows, mean 26.51 minutesfor both, still0ticks during the main-thread parse and (in four runs here)yesfor the worker. A loop of any length blocks timers while it runs: what changes is how long the freeze lasts. For a job this short the worker's answer comes back within a few milliseconds, so on a fast machine the ticker may not get a turn and the last line could sayno: that is the cost of starting a worker, not a bug. - 2
Move a buffer, copy a buffer
At the end of the first example (with
i < 4000to keep it quick) add a worker that answerse.data.byteLength, thenconst big = new ArrayBuffer(8000000); worker.postMessage(big, [big]);. After the call,big.byteLengthis0: the buffer was moved, and the page no longer owns it. Send the same buffer without the second argument and the page keeps its 8,000,000 bytes, while the worker gets a copy of its own. - 3
Pretend the browser is old
In the second example, change
typeof OffscreenCanvas === "function" &&tofalse &&. The Console now saysno OffscreenCanvas: draw on the main thread, fewer dots, theInvalidStateErrorline disappears (the canvas was never transferred, sogetContextworks), and the relative-fetcherror stays. Test your fallback path this way: you cannot find an old browser on demand.
Code & diagrams
Press the two buttons on the page and watch the ticker. The log uses the ticker's count, which is exact: a synchronous loop lets no timer fire. The parse time is not logged.
Preview
Console
Nothing logged yet.Expected console output
main thread: 400,000 rows, mean 26.50 minutes
ticker moved while the main thread parsed: 0
worker: 400,000 rows, mean 26.50 minutes
ticker moved while the worker parsed: yesThe worker is built from text, so it has no DOM, no d3 import (it has its own tiny random generator) and no base address for fetch. The two error lines are what this browser printed.
Preview
Console
Nothing logged yet.Expected console output
dots drawn in the worker: 200,000
main thread getContext after the transfer -> InvalidStateError: Failed to execute 'getContext' on 'HTMLCanvasElement': Cannot get context from a canvas that has transferred its control to offscreen.
worker fetch of a relative URL -> TypeError: Failed to execute 'fetch' on 'WorkerGlobalScope': Failed to parse URL from /data/lumen/stations.csvBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Send the canvas without transferring it
In the second example, change { type: "draw", canvas: offscreen, count: 200000 }, [offscreen] to { type: "draw", canvas: offscreen, count: 200000 }.
Myth vs fact
Myth
A worker makes any code faster.
Fact
It keeps the page responsive. The work takes about as long (a little longer with copying), and starting a worker has a cost. Short jobs are slower in a worker.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Cloning a big array of objects can itself block the main thread. Send flat typed arrays and transfer their buffers, or send the CSV text and parse inside the worker, and send back only what the page needs, such as 300 bin counts.
Remember this
- 1
The problem this solves: parsing a year of trips or binning 200,000 points can take long enough that the page cannot scroll, click or animate meanwhile. A long block of work on the main thread is a long task; users notice anything past about 50 milliseconds.
- 2
The main thread does everything. Your script, event handlers, layout and painting run on one thread, one after another. While a loop runs, a
setIntervalcannot fire and a click waits. The first example proves it: a ticker moves every 4 milliseconds, yet while the main thread parses 400,000 CSV rows it moved 0 times. - 3
A Web Worker is a second thread.
new Worker(url)starts a script on another thread. It has no page (nodocumentorwindow), onlyself. You talk withpostMessageandonmessage, and data is copied. The worker parses the same 400,000 rows while the ticker keeps moving: the log saysyes. - 4
OffscreenCanvas lets a worker draw.
canvas.transferControlToOffscreen()hands a canvas's drawing to a worker, which callsgetContext("2d")on it: the second example draws 200,000 dots that way. Big binary objects are transferred, not copied: list them in the second argument ofpostMessageor the browser throws aDataCloneError. After the transfer the main thread may not touch the canvas:getContextthrowsInvalidStateError. - 5
Limits and support. A worker cannot use the DOM or D3 selections. Starting one costs time and memory, so a job of a few milliseconds is slower in a worker. In this page's preview, a worker built from text (a
blob:address) cannotfetcha relative path: the example logs the realTypeError. A worker file served from your Vite project can. Older Safari (before 16.4) has noOffscreenCanvas, so testtypeof OffscreenCanvasand fall back to the main thread.
Explain it without notes
Why did the ticker move 0 times while the main thread parsed?
What can a worker not do, and how does it get data from the page?
Practice
Write canUseWorkerCanvas(canvas) that returns true only when OffscreenCanvas and transferControlToOffscreen both exist.
Make the first example's worker answer with the busiest station instead of the mean minutes. What goes in its message?
Trade-offs
- ↔
Workers keep the page responsive for heavy work, at the price of message copying, no DOM access and harder debugging. OffscreenCanvas moves drawing off the main thread, but then hit-testing (Topic 12.3) must be answered from data the main thread also keeps.
Done when you can
I can explain why a long loop freezes the page.
I can start a worker, send it data and read its answer.
I know what transferring means and why
OffscreenCanvasneeds it.I add a feature check and a main-thread fallback.