Web Workers¶
By default your application shares a thread with the page. textual-wasm build --worker moves
it to a Web Worker instead.
$ textual-wasm build myapp.main:App myapp -o dist/ --worker
built dist - 10 files, 248 KiB, 11 requirement(s)
python runs in a Web Worker; no COOP/COEP headers required
Nothing about your application changes. The driver talks to the same four-member host
contract either way; in worker mode that contract is satisfied over postMessage instead of
by direct call.
What it fixes¶
Pyodide has no threads. A Python call that takes a second is a second in which nothing else on that thread runs — and when that thread is the page’s, the whole tab is frozen: no scrolling, no animation, no other component doing anything.
Measured against tests/blocking_app.py,
which spends about a second in a synchronous loop, sampling requestAnimationFrame on the
main thread throughout:
Build |
Longest gap between frames |
|---|---|
default (main thread) |
1333 ms |
|
16.8 ms |
16.8 ms is one frame at 60 Hz — the page never stopped painting. The measurement is the longest gap between frames, not a frame count: over a window longer than the blocking call, a frame count dilutes a real stall into an average that looks healthy. The first version of this measurement reported “340 frames rendered” for a build that had frozen for a third of a second.
textual-wasm check --worker runs the full four-runtime comparison against a worker build, so
“renders identically” is checked on all three engines, on every commit.
What it does not fix¶
It does not make Python faster. The work takes just as long. The freeze moves off the thread the user can see, which is the entire benefit.
It does not give you threads. sys._emscripten_info.pthreads is False inside a worker
exactly as it is on the main thread — Pyodide is not built with -pthread, and its ABI
forbids it in linked libraries. @work(thread=True) is unavailable in either mode. Use
@work without thread=True, which is cooperative and works normally.
It does not need cross-origin isolation. No COOP/COEP headers, so a
worker build deploys to GitHub Pages and every other header-less static host, exactly like the
default. SharedArrayBuffer would only be needed to block the worker waiting on main-thread
input, and Textual never does: its input path is a queue an async loop drains, so a message
arriving whenever it arrives is already the right shape.
Which to choose¶
Use the default when your app is mostly waiting on the user. It is one fewer moving part, and the boot path is shorter.
Use --worker when any of these is true:
Something in your app blocks for longer than a frame — parsing a large file, a tight loop, a big
on_mount.The page has other content around the terminal. A frozen tab is much more obvious when there is a sidebar or a form next to it that stops responding too. See embedding.
You are mounting the terminal inside a framework component whose own reactivity shares the main thread — the Svelte example is the case in point.
Writing a custom page for worker mode¶
The template contract is unchanged: an element with id="terminal" and a
module script loading ./main.mjs. main.mjs reads the worker flag out of app.json and
starts the worker itself, so a template written for the default build works in worker mode
with no edit.
Two host capabilities are forwarded back to the page, because a worker has neither window
nor document:
App.open_url()— becomeswindow.openon the page’s thread.Textual’s file delivery — becomes a download-triggering anchor on the page’s thread.
If you register your own host object instead of using the shipped page, those two are optional members of the contract:
{
write(text), onData(callback), onResize(callback), cols, rows, // required
openUrl(url, newTab), deliverFile(href, filename), // optional
}
Omitting them is fine on the main thread, where the driver falls back to the page’s own globals. In a worker there is nothing to fall back to, so a host that omits them will raise when the application tries to open a link.
The data channel works in both modes¶
The data channel behaves identically in both modes. On the main thread Python calls the page
directly; here the same two-member contract is satisfied by a postMessage. Adding --worker
changes nothing in an application or a page, and tests/test_bridge_browser.py checks it by
running one harness against both builds and comparing the two sets of results.
The channel carries text, and not arbitrary values, for exactly this reason. On the main
thread Python could be handed a live JsProxy; here the identical call arrives as a
structured clone. The two differ in proxy lifetime, in whether a mutation is visible across
the boundary, and in whether a function survives the trip. A channel whose semantics depended
on a build flag would turn --worker from a performance decision into an API decision.