Shipped as the default in v8.32 on 13 August 2026. Everything measured below comes from the repository's own records — ADR-024, the feasibility note that opened it, and the transferred-canvas finding that closed the last objection to it.
Until August, the engine that does the actual pixel work in Image Horse — stamp_tool, compiled from Rust to WebAssembly — ran on the same thread as the interface. Every blur, every export, every batch run competed with painting. A heavy operation blocked the main thread for 129–137 ms, and strokes stuttered while it did.
Image Horse already ran a worker. codec.worker.ts encodes WebP and JPEG off-thread, and it works because what it sends across is small, or already shaped like a buffer. The engine is a different animal. Its whole value on the render path is a view straight into WASM linear memory, blitted to the canvas without a copy. This post is about what happens to that view at a thread boundary, and what we did instead.
The obvious way is impossible
postMessage has two modes and neither one fits. It copies, which buys you the copy you spent the whole engine avoiding. Or it transfers, which hands the buffer over and leaves the engine without its own heap. A view into linear memory is not a thing you can send; it is a thing that is only meaningful next to the memory it points at.
The sharpest case was flushToCanvas. It reads the canvas width and height, then recomposites against them — per frame, in the hot path. Split that across a boundary and it stops being one operation and becomes a read, a wait, and a write against numbers that may have changed while you waited. There were nine sites shaped like that. This was the worst of them.
So the canvas went too
The decision was to move the engine and the main canvas into the worker, rather than leaving the canvas behind and marshaling pixels back to it. That sounds like more work and it is less: under this arrangement flushToCanvas never crosses the boundary at all. The worst read-modify-write site disappears instead of needing a careful rewrite, and every one of the arguments against moving the canvas was measured first — zoom and pan survive the transfer 11/11 across four browsers, overlays stay pinned 9/9, and desynchronized is honored 4/4.
The <canvas> element stays in the page, where layout and pointer events still need it. What leaves is its drawing surface, handed over once with transferControlToOffscreen and never handed back.
Main thread
- React, pointer input, layout
<canvas>— the element stays, the surface is gone- No engine. No WASM memory. Nothing to block on.
Engine worker
stamp_tool, with its own WASM linear memoryOffscreenCanvas— the blit lands here, never crosses back- One queue, drained one call at a time
One port per document
The whole arrangement rests on a single rule: every mutation of the document you are editing reaches the engine through one message queue.
That rule is not tidiness. OpLog::append records arrival order — there is no sequence number anywhere in an Op — and a MessagePort is FIFO. So message order is append order, and the undo log after the move is byte-identical to the one before it. Open a second port, or reach the engine anywhere outside the queue, and that guarantee is gone without a single error in the console.
Which makes it the wrong kind of thing to remember and the right kind of thing to test. engineOwnership.contract.test.ts fails on a second writer to the live handle, on an engine built outside the owner, and on an assignment that bypasses the seam. All three were mutation-tested rather than assumed.
The rule also needed a correction, five days in, and the correction is the interesting half. Two modules legitimately build their own engine — batch export, and the settings panel behind it — for a document you are not editing. Routing those through the live port would put their operations in the live document's log, so undo would replay edits to a photo nobody opened, and a forty-photo batch would queue behind the one open image. The first version of the rule said "every mutation", a structural test enforced exactly that, and the obvious way to make the test pass was to introduce the bug.
Latency was never the problem
The instinct is that a round trip per call is unaffordable, especially on a pointer-move handler. It was measured before anything was built, and it is not.
| What | Measured | Against |
|---|---|---|
| Fixed round trip, median | 0.100 ms | 0.6% of one 60 fps frame |
| Fixed round trip, p95 | 0.300 ms | Over 50 pings |
| Flush at 3.1 MP, in the worker | 22.1 ms | The same on the main thread |
Warm adjust_sharpen, in the worker | 392 ms | 419 ms on the main thread |
The feasibility note put its own conclusion in one line.
Latency is not the obstacle. The obstacle is 117 synchronous reads.
docs/engine-worker-feasibility.md
A later recount raised that considerably — 290 call sites, of which 101 can be fired and forgotten, 27 sit in a hot path, and 162 consume a value and so need both a promise and a rewrite of the code around them.
The truthy trap
Turning a synchronous read into an asynchronous one does not break loudly. That is the entire difficulty. A guard like if (!tool.has_source()) reads perfectly well after the conversion, and if the await is missing the expression is now a promise — and a promise is truthy. The guard stops guarding. Nothing throws, nothing logs, and the tool quietly does the thing the guard existed to prevent.
The variants got worse the further in we went. Array.from(promise) is [], which is also truthy, so a if (!m) throw written specifically to catch a cache miss could never fire — and the arithmetic downstream produced NaN for an entire batch. flatten_text_annotations reports whether a layer had annotations to bake; un-awaited it reads as true after the first layer, so every OpenRaster export would have told you your redo history had been cleared.
TypeScript does not save you here, and the reason is worth writing down. A function returning Promise<number> is assignable to a slot declared => void. One of the pen tool's commit handlers sat in exactly such a slot, so the conversion would have typechecked cleanly while typeof newId === "number" silently went false and paths stopped staying selected.
A
voidreturn is not proof that nothing is consumed.
So the conversions were ratcheted. A contract test pins the number of unconverted value-consuming sites so it can only go down, an audit script — not a hand-written list — is the authority on what is left, and every batch was mutation-tested: change the code so the bug is present again, and confirm a test goes red. Twice the tests caught what the audit structurally could not see, because the audit reads the text at the call site and a guard one line below is invisible to it.
Six stages, each one shippable
The plan was staged on purpose. Stages one and two introduce no worker and change no behavior at all — they establish the single seam and remove the read-modify-write sites — and they were worth shipping even if everything after them had been abandoned. That is the point of putting them first. Stage three built the worker and left it switched off, and the build emitted no worker chunk at all, because nothing imported it yet. That was written down as the honest status rather than smoothed over.
Every stage was reversible by itself, and the whole arc sat behind a flag that stayed off for five weeks. It became the default on 13 August 2026, in v8.32.
The switch is still there
Set ih_engine_worker to 0 and reload, and the engine goes back on the main thread. It takes effect on the next load, like every kill switch in this app. It is there because a change this structural should be something you can undo from your own browser without waiting for a release from us.
What you get for it is unglamorous and it is the whole point: the main thread's blocking time per heavy operation went from 129–137 ms to none. The engine does exactly what it did before, in exactly the order it did it. It just stopped doing it where you were trying to draw.