Command Palette

Search for a command to run...

Hectal
PHASE 12Advanced ~27 min· topic 5 of 8

Topic 12.5

Keeping the Page Responsive

In one line

Running a model on the page's main thread freezes the page: no clicks, no typing, no animation. A Web Worker runs the model on another thread and talks to the page by messages. You learn messages, request ids, progress, transferables, cancelling and errors.

Think of it like this

A restaurant with one person who both cooks and serves. While they chop vegetables for a big order, nobody gets their water or their bill, and the customers think the place has closed. Hire a cook in the kitchen, and the waiter keeps serving while the food is made; they pass notes through a hatch. The main thread is the waiter. A Web Worker is the cook. Messages are the notes through the hatch.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Main thread
The one thread that runs the page's JavaScript and also handles clicks, typing and drawing.
Web Worker
A script that runs on a separate thread. It cannot touch the page, and talks to it with messages.
postMessage
The method both sides use to send a message to the other. The data is copied (structured clone).
Structured clone
The browser's way of copying data between threads. It handles objects, arrays and typed arrays, but not functions.
Transferable
An object, such as an ArrayBuffer, whose ownership moves to the other thread without copying.
Request id
A number put on each request and repeated on its replies, so answers can be matched to questions.
Cooperative cancel
Cancelling by sending a message the worker checks between small steps, so it can stop cleanly.
terminate()
Stops a worker immediately. Fast, but everything the worker had in memory, such as a loaded model, is lost.
Blob URL
A temporary URL for in-memory content, created with URL.createObjectURL. Used here to make a worker without a separate file.

Step by step

01Feel the difference

The first live example runs a loop of 150 million additions. First on the main thread, then in a worker. Before each, it schedules a zero-delay timer. The question it logs is: did that timer fire before the work finished?

On the main thread the answer is no, because nothing can run while the loop does. In the worker the answer is yes. The page in a real product would be able to animate a spinner and react to a Cancel click.

Feel the differencediagram
Rendering diagram…

02A small protocol

The second example wraps the worker in one function, call(message, transfer, onMessage). It assigns an id, sends the message, listens for messages with that id only, passes progress messages to a callback, and returns a promise that resolves on the final message. The page code reads like ordinary await code, and the worker's messages stay an implementation detail.

03Four cases in one run

(1) A normal batch with progress. (2) A cancel triggered by the second progress message, not by a timer, so the run is the same on every machine. (3) A transferred buffer. (4) A message type the worker does not know, which comes back as an error message instead of silence.

Always give the worker a way to say "I did not understand". A worker that ignores unknown messages makes bugs invisible: the page waits for a reply that never comes.

04Errors that are not messages

Three more things can go wrong. The worker script can fail to load or throw an uncaught error: the page gets an error event on the worker (worker.onerror). **Watch the trap in async handlers:** measured in Chromium, a plain onmessage that throws reaches worker.onerror, but an async onmessage that throws only rejects its promise, and the page hears nothing (the worker itself gets an unhandledrejection event). Since model code is almost always async, wrap the body of the handler in try … catch and answer with an error message, the way the second example answers an unknown message type. A message can fail to be copied, for instance if it contains a function: that throws a DataCloneError on postMessage. And the worker can die silently if the browser is short of memory. Give every request a timeout (Topic 1.4) so the page never waits forever.

worker errors (not live)whole filejs
worker.onerror = (e) => {
  // the script failed to load, or threw an uncaught error
  console.log("worker failed:", e.message);
  failAllPendingRequests("The assistant stopped. Please try again.");
};
worker.onmessageerror = () => {
  // a message arrived that could not be read
};

05Where the model lives

In a real project the worker loads the model (Topic 12.2) on its first message and keeps it. This site's own embedding worker is a module worker that does exactly that and reports loading, embedded, done and error messages, each with the request id. The page shows the loading messages as the progress bar from Topic 12.4.

WebGPU is also available inside workers (MDN documents WorkerNavigator.gpu), so the worker can use the graphics chip while the page stays free.

page.js (real project, not live)whole filejs
const worker = new Worker(new URL("./model-worker.js", import.meta.url), { type: "module" });
worker.postMessage({ id: 1, type: "classify", texts: ["Can I get a refund?"] });
worker.onmessage = (e) => {
  if (e.data.type === "loading") showProgress(e.data.loaded, e.data.total);
  if (e.data.type === "done") showResult(e.data.results);
};

Try it yourself

  1. 1

    Cancel earlier and later

    In the second example change m.done === 2 to m.done === 10. The worker has done at least ten items when it stops.

    The output line stays "a few items, not all 50". The exact count depends on message timing, which is why the example does not print it.

  2. 2

    Make the worker throw

    In the second example's worker source add if (m.type === "boom") throw new Error("model crashed"); before the unknown message line. Then, before worker.terminate();, add worker.onerror = (e) => console.log("worker.onerror:", e.message); and worker.postMessage({ type: "boom", id: 77 });, and wait a moment with await new Promise((r) => setTimeout(r, 200));.

    Nothing is logged: the handler is async, so the throw becomes a rejected promise, not an error event. A handler that is an ordinary function behaves differently: a separate tiny worker with self.onmessage = () => { throw new Error("boom"); }; does reach worker.onerror, with the message Uncaught Error: boom. The fix in real code is the try … catch that posts an error message, so both kinds of failure look the same to the page.

  3. 3

    Use the buffer after sending it

    After the transfer line add new Float32Array(buffer); and look at the console.

    It fails with TypeError: Cannot perform Construct on a detached ArrayBuffer: a buffer whose ownership moved is "detached". Sending it a second time fails too, with a DataCloneError. If you need the data after sending it, send a copy: buffer.slice(0).

Code & diagrams

The freeze: main thread versus worker

Only booleans are logged. The loop takes a noticeable moment on any machine, so the main-thread run really does hold the page for that moment.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

main thread: page stayed responsive during the job: false
worker: page stayed responsive during the job: true
A worker with ids, progress, cancel, a transferred buffer and an error

The worker's "model" is a stand-in (a refund/return word check), not a neural network. The protocol is what matters. The cancel is sent when the 2nd progress message arrives.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

progress 1/4
progress 2/4
progress 3/4
progress 4/4
done ["returns","other","returns","other"]
cancelled after a few items, not all 50
bytes before sending: 4000
worker summed 1000 numbers: 1000 | bytes after sending: 0
error: unknown message type: translate

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

A worker that never takes a breath

In the second example, delete the line await new Promise((r) => setTimeout(r, 5)); // lets a cancel message get in between items inside the worker source.

terminal
$ The cancel line of the output
── what you'll see ──
done after all 50

Myth vs fact

Myth

Putting code in a worker makes it faster.

Fact

It does not speed up the work. It stops the work from blocking the page, and it can use another CPU core. A transfer or copy of big data may even add time.

Myth

A worker can read the page's variables or the DOM.

Fact

It cannot. It is a separate world with only messages between.

Myth

terminate() is a good Cancel button.

Fact

It stops the work, but it throws away the loaded model, so the next request reloads it. Prefer cooperative cancel for frequent actions.

Myth

Messages are free.

Fact

Each message copies its data. Large arrays should be transferred, or kept in the worker and referenced by id.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Match replies to requests by id, always. With two quick requests in flight, a late reply to the first can overwrite the second's result. The id lets the page drop answers nobody is waiting for. This is the stale-answer guard from Topic 4.7, now across a thread boundary.

  • ▸

    One worker is a queue. A single worker handles messages one after another, so a long job delays everything behind it. For independent jobs you can create a small pool of workers, but each one loads its own copy of the model, so memory is the limit. Often one worker plus a queue and cancel is the right size.

  • ▸

    Keep progress messages cheap and bounded. Sending a message per item for ten thousand items floods the main thread's queue and can make the page janky, which defeats the purpose. Send progress a few times per second, or per batch.

  • ▸

    Test the worker's protocol without a browser by writing the handler as a plain function that takes a message and a post function. Then the worker file is a thin wrapper, and unit tests do not need threads.

Remember this

  1. 1

    The problem this solves: JavaScript on a page runs on one thread, the main thread, which also handles clicks, typing, scrolling and drawing. A model run is heavy computing that can take hundreds of milliseconds or seconds. While it runs, the page cannot respond: buttons do not react and the progress bar stops moving. This topic moves the heavy work into a Web Worker, a second thread, so the page stays alive.

  2. 2

    See the freeze. The first live example runs the same loop twice. On the main thread, a timer scheduled before the loop cannot fire until the loop is over, so the page was not responsive and the log says false. In a worker, the main thread is free, the timer fires, and the log says true. The example prints only these two booleans, never milliseconds, so the result is the same on every machine.

  3. 3

    Creating a worker. new Worker(url) starts a worker that runs the script at url. In a real project you write new Worker(new URL("./model-worker.js", import.meta.url), { type: "module" }), and your bundler (Vite, Next.js) handles the file. The live examples build the script from a Blob URL (URL.createObjectURL(new Blob([source]))), which works in this sandbox. A worker has no access to the page's DOM or variables: it is a separate world.

  4. 4

    Messages. The two sides talk only with postMessage(data), received in onmessage. The data is copied with the structured clone algorithm, so you can send objects, arrays, numbers and typed arrays, but not functions or DOM nodes. Design a small protocol: every message has a type, and every request has an id. The reply carries the same id, so the page can match answers to questions when several are in flight, and ignore answers nobody is waiting for (the same trap as Topic 4.7).

  5. 5

    Progress and results are different messages. One request can produce many progress messages and exactly one final message: done, cancelled or error. The second live example shows all of them for a batch of four texts: progress 1/4 to 4/4, then done with the labels. The page code resolves its promise only on the final message.

  6. 6

    Transferables. Copying a large array for every message is slow. For an ArrayBuffer you can pass it in the second argument of postMessage as a transferable: ownership moves to the other side, with no copy. The example sends a 4000-byte buffer: before sending its byteLength is 4000, afterwards it is 0, because the sender no longer has it. That is a feature, and also a trap if you try to use the buffer again.

  7. 7

    Cancelling. A worker busy in a loop cannot hear a cancel message until the loop yields to the event loop. So long work must be written in small steps with a pause between them (here await a zero-time timer), and check a cancelled set at each step. The example sends cancel when the second progress message arrives, and the worker answers cancelled. The heavy-handed alternative is worker.terminate(), which kills it at once but also destroys the loaded model, so the next request pays the load again (Topics 12.2 and 12.4).

Explain it without notes

01

Why does heavy computing freeze the page, and how does a worker fix it?

02

Why give every request an id?

03

What are transferables, and what is the catch?

04

How do you cancel work in a worker, and what are the trade-offs?

Practice

01

Add a "ping" message to the second example's worker that replies { id, type: "pong" }, and a page-side call with a 200 ms timeout that rejects with "worker did not answer" if there is no reply.

02

Change the page-side call so that a late reply for an id that is no longer pending is logged and ignored instead of throwing.

Trade-offs

  • ↔

    A worker keeps the page smooth but adds a message protocol, a second place for bugs and a copy of the model in memory. For a tiny task a few milliseconds long, the main thread is simpler.

  • ↔

    Cooperative cancel needs the work to be written in small steps, which is more code. terminate() is trivial but throws away the loaded model.

Done when you can

  • I can explain why heavy work freezes the main thread and show it with a test.

  • I can create a worker, define a message protocol with ids and read progress and result messages.

  • I can transfer a buffer and explain what happens to the original.

  • I can implement cancellation and explain why the worker must yield.

  • I can handle worker errors, unknown messages and a worker that never replies.