191. The browser playground
The box below runs the Rust engine, compiled to WebAssembly, inside this page. Getting it there took a lazy download, a worker, tickets for every keystroke, and a plan for the day Rust panics in your browser.
- Explain how the playground loads the Rust engine only when a reader asks for it.
- Use tickets to drop compile results that arrive after a newer edit.
- Say what a Rust panic does to a WebAssembly instance, and why the playground replaces its worker.
- List what the browser smoke test checks about the two engines.
The engine in the page
This one starts on the Rust engine. Edit the program and watch the status line under the output:
The decisions should read ps (line 1) {x, vx} → struct of arrays,
followed by the loop, the typed columns and the bounds proof. Press
TS and they don’t change, byte for byte: that is chapter 188’s
parity, now in front of you. One thing does differ. Type let = ; on
each engine and compare: syntax errors come from two parsers, oxc and
acorn, worded their own way.
A shop keeps a heavy machine in the back room and only switches it on when a customer asks. Orders go through a hatch, each with a numbered slip. If the machine jams, nobody tries to fix it mid-shift: it is wheeled out and a new one wheeled in.
A playground is the compiler running in a web page: the reader edits
a program and sees the compiler’s output, here the decisions, the
compiled code and a check that both versions print the same thing. The
book’s Playground component has two engines, the TypeScript bundle
and the Rust engine as WebAssembly, switched with the TS / Rust
toggle or the engine prop.
Only when asked
The .wasm is 1.4 MB, so pages that never use it mustn’t pay for it.
Chapter 189’s build.mjs --book copies the web glue into
book/src/vendor and the module into book/public/wasm. Files in
public/ are served as they are, never bundled. That’s why the script
also rewrites one line of the glue: the default
new URL('viscosity_wasm_bg.wasm', import.meta.url) would point the
bundler at a file next to the glue, and it becomes
'/wasm/viscosity_wasm_bg.wasm'.
The loading happens in a worker. A worker runs a script on a
background thread, without touching the page, and the two talk only by
messages: postMessage on one side, onmessage on the
other[1]. play.worker.ts loads the engine
like this, trimmed:
let rust: Promise<Wasm> | null = null;
const loadRust = () => (rust ??=
import('../../vendor/viscosity_wasm.js')
.then(async (m) => {
await m.default({ module_or_path:
BASE + 'wasm/' + WASM });
return m;
})
.catch((err) => {
rust = null; throw err;
}));
(WASM is viscosity_wasm_bg.wasm.) ??= stores the promise the
first time, so every later request awaits
the same one. A failed download sets it back to null, so the next
request can try again instead of reusing a rejection forever. The
TypeScript engine, by contrast, is imported straight into the page and
called synchronously.
One ticket per keystroke
Every edit compiles, and a Rust compile is a round trip to the worker. Replies can arrive after a newer edit, or after the reader has switched to the TypeScript engine. So each compile takes a ticket:
const ticket = ++latest.current;
const show = (r, error, note) => {
if (ticket !== latest.current)
return; // a newer edit won
setRes(r); setErr(error);
setStatus(note);
};
Both engines share the counter. Step through one reader’s session:
When Rust panics
The Rust engine shouldn’t panic: the fuzzer of chapter 193 hunts for
inputs that do, and its finds are fixed. But the playground must
survive one anyway. In native Rust a panic unwinds: Rust walks back
up the stack and cleans up each function’s data on the
way[4]. catch_unwind can stop an unwinding
panic and hand you an Err[5]. Watch
the cleanup release a RefCell’s borrow:
use std::cell::RefCell;
use std::panic;
fn main() {
let seen = RefCell::new(vec![1]);
let r = panic::catch_unwind(
panic::AssertUnwindSafe(|| {
let mut v =
seen.borrow_mut();
v.push(2);
panic!("no booms");
}),
);
println!("caught: {}", r.is_err());
// Unwinding dropped the borrow
// guard on its way out.
let ok =
seen.try_borrow_mut().is_ok();
println!("free again: {ok}");
println!("{:?}", seen.borrow());
}
WebAssembly built by cargo doesn’t unwind. By default Rust panics there
compile with panic=abort, “which aborts the WebAssembly instance on
panic”[3]. From JavaScript, the abort is a
trap, thrown as a WebAssembly.RuntimeError[2].
And the instance is still there afterwards, in whatever state the
panic left it. I tried it with a small crate, built like the engine (rustc 1.97,
wasm-bindgen 0.2.106, Node v22). remember(s) borrows a thread-local
RefCell<Vec<String>>, pushes s, and panics on "boom"; busy()
asks whether the RefCell is still borrowed; shout uppercases a
string and touches nothing else:
a -> 1
boom -> RuntimeError: unreachable
busy -> true
shout -> STILL HERE
c -> RuntimeError: unreachable
No destructor ran, so the borrow guard was never dropped. The RefCell
stays mutably borrowed forever, and every later call that touches it
panics on borrowing[6], which traps again. An
unrelated function still works, which is worse: the instance looks
alive. So the worker reports a trap as fatal:
post({ id, error: err.message,
fatal: err instanceof
WebAssembly.RuntimeError });
On a fatal reply the page calls terminate() on the
worker[1] and forgets it. Requests still
queued on it get an error at once, so none waits forever. The next
compile starts a new worker with a new instance, and new memory.
scripts/smoke.mjs checks all this in a real browser (Playwright), on
chapter 94’s Playground. On the TS engine, no .wasm may be requested.
After pressing Rust, exactly one request, for
/wasm/viscosity_wasm_bg.wasm. The compiled code and the decisions
text must be identical on both engines. Run both on the Rust
engine’s output must print the same as the original, switching back
to TS must work, and there must be no console errors. It also measures
the toggle buttons: tap targets at least 44 pixels high and wide, and
the whole Playground fitting a 440-pixel-wide screen. Each Run both
gets a fresh worker of its own, stopped after 10 seconds, so a program
that loops forever can’t hang the page.
Your turn
Model the playground’s Rust engine. send starts a worker if there is none (worker = true, started += 1), then returns a new ticket: latest + 1, stored in latest. reply handles an answer: a Fatal reply always kills the worker (worker = false), even a stale one. Then, only if ticket == latest, set shown to the code, or to error: <msg> for Error and Fatal.
// What the worker sends back.
enum Reply {
Code(String),
Error(String),
// A trap: the instance is broken.
Fatal(String),
}
struct Pg {
latest: u32, // newest ticket
shown: String, // on screen
worker: bool, // a live worker?
started: u32, // workers started
}
impl Pg {
// A new compile: a worker (start
// one if there is none), a ticket.
fn send(&mut self) -> u32 {
todo!()
}
// A reply for `ticket` arrives.
fn reply(
&mut self,
ticket: u32,
r: Reply,
) {
todo!()
}
}What you learned
- A playground runs the real compiler in the page. The Rust engine
is fetched only when a reader picks it, from
public/wasm. - It runs in a worker: messages in, messages out, one memoized loading promise that resets on failure.
- Each compile takes a ticket. A reply is shown only if its ticket is still the latest, whichever engine it came from.
- A Rust panic in WebAssembly is a trap (
RuntimeError). Nothing unwinds, so state can stay broken. The worker is replaced. - The browser smoke test checks lazy loading, parity, Run both and the toggle’s size.
Check your understanding
0/6- 1.
A chapter page has one Playground with the default
engine="ts". When is the.wasmdownloaded? - 2.
The reader types “abc” quickly, making compiles 7, 8 and 9. Reply 9 arrives, then reply 8. What is on screen?
- 3.
Why does a panic in the Rust engine show up as
WebAssembly.RuntimeError: unreachablerather than as Rust’s panic message? - 4.
In the author’s test, after a trap, an unrelated exported function still worked. Why does the playground replace the worker anyway?
- 5.
In
play.worker.ts,loadRustresetsrust = nullif loading fails. What would happen without that? - 6.
What does
scripts/smoke.mjscompare after pressing the Rust toggle on chapter 94’s Playground?
References
- [1] MDN contributors. Using Web Workers. MDN Web Docs; source files/en-us/web/api/web_workers_api/using_web_workers/index.md in github.com/mdn/content. developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers (checked 2026-10-31)
- [2] MDN contributors. WebAssembly.RuntimeError. MDN Web Docs; source files/en-us/webassembly/reference/javascript_interface/runtimeerror/index.md in github.com/mdn/content. developer.mozilla.org/en-US/docs/WebAssembly/Reference/JavaScript_interface/RuntimeError (checked 2026-10-31)
- [3] The wasm-pack contributors. The wasm-pack book: wasm-pack build, and Cargo.toml configuration. wasm-pack documentation; source docs/src in github.com/wasm-bindgen/wasm-pack (moved from rustwasm/wasm-pack). wasm-bindgen.github.io/wasm-pack/book/commands/build.html (checked 2026-10-31)
- [4] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)
- [5] The Rust Project Developers. std::panic::catch_unwind. The Rust standard library; source library/std/src/panic.rs in github.com/rust-lang/rust. doc.rust-lang.org/std/panic/fn.catch_unwind.html (checked 2026-10-31)
- [6] The Rust Project Developers. Module core::cell (std::cell), Rust standard library documentation. Rust standard library API documentation; source in library/core/src/cell.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/cell/index.html (checked 2026-10-31)