Command Palette

Search for a command to run...

Hectal
PHASE 12Advanced ~23 min· topic 5 of 7

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.

Move the job to a workerdiagram
Rendering diagram…

03Let the worker draw

The second example transfers a canvas and the worker draws 200,000 dots.

hand a canvas to a workerwhole filejs
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ type: "draw", canvas: offscreen }, [offscreen]); // the second argument moves it

04Meet 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. 1

    A smaller job

    In the first example, change i < 400000 to i < 4000. The log says 4,000 rows, mean 26.51 minutes for both, still 0 ticks during the main-thread parse and (in four runs here) yes for 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 say no: that is the cost of starting a worker, not a bug.

  2. 2

    Move a buffer, copy a buffer

    At the end of the first example (with i < 4000 to keep it quick) add a worker that answers e.data.byteLength, then const big = new ArrayBuffer(8000000); worker.postMessage(big, [big]);. After the call, big.byteLength is 0: 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. 3

    Pretend the browser is old

    In the second example, change typeof OffscreenCanvas === "function" && to false &&. The Console now says no OffscreenCanvas: draw on the main thread, fewer dots, the InvalidStateError line disappears (the canvas was never transferred, so getContext works), and the relative-fetch error stays. Test your fallback path this way: you cannot find an old browser on demand.

Code & diagrams

A frozen page against a worker New tab

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

Press Run to see the page.

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: yes
OffscreenCanvas in a worker, with its failures New tab

The 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

Press Run to see the page.

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.csv

Break 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 }.

terminal
$ Console
── what you'll see ──
Uncaught DataCloneError: Failed to execute 'postMessage' on 'Worker': An OffscreenCanvas could not be cloned because it was not transferred.

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. 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. 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 setInterval cannot 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. 3

    A Web Worker is a second thread. new Worker(url) starts a script on another thread. It has no page (no document or window), only self. You talk with postMessage and onmessage, and data is copied. The worker parses the same 400,000 rows while the ticker keeps moving: the log says yes.

  4. 4

    OffscreenCanvas lets a worker draw. canvas.transferControlToOffscreen() hands a canvas's drawing to a worker, which calls getContext("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 of postMessage or the browser throws a DataCloneError. After the transfer the main thread may not touch the canvas: getContext throws InvalidStateError.

  5. 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) cannot fetch a relative path: the example logs the real TypeError. A worker file served from your Vite project can. Older Safari (before 16.4) has no OffscreenCanvas, so test typeof OffscreenCanvas and fall back to the main thread.

Explain it without notes

01

Why did the ticker move 0 times while the main thread parsed?

02

What can a worker not do, and how does it get data from the page?

Practice

01

Write canUseWorkerCanvas(canvas) that returns true only when OffscreenCanvas and transferControlToOffscreen both exist.

02

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 OffscreenCanvas needs it.

  • I add a feature check and a main-thread fallback.