Viscosity
Review
Part XI · Viscosity in Rust · Day 48

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.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • 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:

PlaygroundRust, as WebAssembly, in your browser

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.

Intuition

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.

Definition

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:

Step throughTickets, stale replies and a fresh worker
Messages between the playground page and its Rust worker, with each reply shown or droppedpageworkerlatest = 1compile #1fetching about 1.4 MB…showing: Loading the Rust engine…
The reader presses Rust. The page makes ticket 1 and starts a worker. Its first job is to fetch and instantiate /wasm/viscosity_wasm_bg.wasm, which takes a moment.
1/7

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:

Try itNative Rust unwinds
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());
}
Open in Rust Playground ↗

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.

Under the hood

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

ExerciseTickets and a fresh worker

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!()
    }
}
Open in Rust Playground ↗

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. 1.

    A chapter page has one Playground with the default engine="ts". When is the .wasm downloaded?

  2. 2.

    The reader types “abc” quickly, making compiles 7, 8 and 9. Reply 9 arrives, then reply 8. What is on screen?

  3. 3.

    Why does a panic in the Rust engine show up as WebAssembly.RuntimeError: unreachable rather than as Rust’s panic message?

  4. 4.

    In the author’s test, after a trap, an unrelated exported function still worked. Why does the playground replace the worker anyway?

  5. 5.

    In play.worker.ts, loadRust resets rust = null if loading fails. What would happen without that?

  6. 6.

    What does scripts/smoke.mjs compare after pressing the Rust toggle on chapter 94’s Playground?

References

  1. [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. [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. [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. [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. [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. [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)