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.
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.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.
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
Cancel earlier and later
In the second example change
m.done === 2tom.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
Make the worker throw
In the second example's worker source add
if (m.type === "boom") throw new Error("model crashed");before theunknown messageline. Then, beforeworker.terminate();, addworker.onerror = (e) => console.log("worker.onerror:", e.message);andworker.postMessage({ type: "boom", id: 77 });, and wait a moment withawait new Promise((r) => setTimeout(r, 200));.Nothing is logged: the handler is
async, so the throw becomes a rejected promise, not anerrorevent. A handler that is an ordinary function behaves differently: a separate tiny worker withself.onmessage = () => { throw new Error("boom"); };does reachworker.onerror, with the messageUncaught Error: boom. The fix in real code is thetry … catchthat posts anerrormessage, so both kinds of failure look the same to the page. - 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 aDataCloneError. If you need the data after sending it, send a copy:buffer.slice(0).
Code & diagrams
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
Console
Nothing logged yet.Expected console output
main thread: page stayed responsive during the job: false
worker: page stayed responsive during the job: trueThe 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
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: translateBreak 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.
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
postfunction. Then the worker file is a thin wrapper, and unit tests do not need threads.
Remember this
- 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
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 saystrue. The example prints only these two booleans, never milliseconds, so the result is the same on every machine. - 3
Creating a worker.
new Worker(url)starts a worker that runs the script aturl. In a real project you writenew 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
Messages. The two sides talk only with
postMessage(data), received inonmessage. 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 atype, and every request has anid. The reply carries the sameid, 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
Progress and results are different messages. One request can produce many
progressmessages and exactly one final message:done,cancelledorerror. The second live example shows all of them for a batch of four texts:progress 1/4to4/4, thendonewith the labels. The page code resolves its promise only on the final message. - 6
Transferables. Copying a large array for every message is slow. For an
ArrayBufferyou can pass it in the second argument ofpostMessageas a transferable: ownership moves to the other side, with no copy. The example sends a 4000-byte buffer: before sending itsbyteLengthis 4000, afterwards it is0, because the sender no longer has it. That is a feature, and also a trap if you try to use the buffer again. - 7
Cancelling. A worker busy in a loop cannot hear a
cancelmessage until the loop yields to the event loop. So long work must be written in small steps with a pause between them (hereawaita zero-time timer), and check a cancelled set at each step. The example sendscancelwhen the second progress message arrives, and the worker answerscancelled. The heavy-handed alternative isworker.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
Why does heavy computing freeze the page, and how does a worker fix it?
Why give every request an id?
What are transferables, and what is the catch?
How do you cancel work in a worker, and what are the trade-offs?
Practice
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.
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.