Making Web Workers observable to agentic browser tools¶
A reusable set of principles — and a small drop-in library — for any web app that pushes heavy
compute into a Web Worker and wants an agent (driving a real browser through the Chrome
MCP / DevTools tools) to be able to see inside that worker. It is written to be orthogonal to
any one app; the running example is pdum.rfb's decode worker, but nothing here is rfb-specific.
The lessons were paid for in a real bug: an OffscreenCanvas viewer rendered black, the
worker was decoding + drawing correctly the whole time, and the single richest log stream in the
client — the worker's console.* — was invisible to the browser automation tools. The fix
had to be found with ad-hoc window.__x hooks that were then thrown away. This doc is what those
hooks became: first-class, off-by-default, runtime-toggleable observability.
The library:
@habemus-papadum/worker-observability(inwidgets/packages/) packages every pattern below. It is generic (framework- and app-agnostic) and private/experimental. rfb adopts it inwidgets/src/debug.ts,widgets/src/worker/entry.ts,widgets/src/RemoteFramebufferView.ts, andwidgets/src/MainThreadPresentView.ts.
The hard constraint: a worker's console.* never reaches the agent¶
The browser MCP tool that reads the console (read_console_messages and friends) surfaces
only main-thread / page console output. A dedicated worker's console.debug/info/error runs
in a separate realm and does not appear there — even with verbose logging forced on. So the
one stream that would localize a worker bug is exactly the one the agent cannot read.
(Playwright's page.on("console") does happen to surface worker console in some setups, but you
cannot rely on that for the MCP tools an agent actually uses, and you certainly cannot rely on it
for a human reading their own DevTools while pairing.)
The rule that follows: anything you want an agent to see must reach the main thread. Design for it. Two mechanisms, both worth having, cover it:
- Forward the worker's logs to main.
- Expose a state snapshot + a log ring buffer on a global handle.
Principle 1 — Forward every worker log line to the main thread¶
Give the worker a logger whose emit path does two things: write to the worker's own console and
postMessage the line to the main thread. On main, a handler re-console.*s each forwarded line
(now MCP-readable) and pushes it into a bounded ring buffer.
worker realm main thread
──────────── ───────────
logger.notice("ws","open")
├─ console.info("[ns:worker] ws", …) (invisible to MCP)
└─ postMessage({__obs, kind:"log", line}) ──▶ onmessage → ingest(line)
├─ ring.push(line) → window.__ns.logs
└─ console.info("[ns:worker] ws", …) (MCP reads THIS)
Design notes that matter in practice:
- Tag every line (
[ns:worker],[ns:view],[ns:decode]) and carry a category (ws/frame/hb) so the console + ring buffer are greppable by subsystem. - A stable discriminant on the message (
__workerObs: true+ achannelstring) lets the host demultiplex observability traffic from its own worker messages with one guard, and lets two independent bridges on one page coexist without cross-talk. - Be robust to un-cloneable args. A log arg might be a function, a DOM node, a
VideoFrame—postMessagethrowsDataCloneErroron those. Post the args as-is, and on a throw retry with a stringified copy, so a rich log arg never wedges the bridge. - Gate the forwarding by tier, mirroring local console emission: errors always, lifecycle notices by default, the verbose per-frame firehose only when debug is on. Forwarding then costs nothing in the normal (quiet) case.
- A bounded ring buffer (last N lines) means the log history is readable without a live
console attached — an agent can
JSON.stringify(window.__ns.logs)at any moment, including after the interesting event has scrolled past.
Principle 2 — A discoverable window.__<ns> registry + state snapshots¶
Even with zero logs, an agent (or a human) should be able to interrogate a live instance from the
console. Install a small registry at window.__<ns> where each instance registers on
construct and deregisters on dispose:
window.__ns.logs— the merged ring buffer.window.__ns.state()/.stats()/.surface()/.capture()— pluggable snapshot providers the host supplies (live getters, not frozen values), so the current state is one call away.window.__ns.instances— every live instance (a page may host several);.viewis the sole/first one for the common single-widget case.
Two refinements that avoid sharp edges:
- The registry object persists even when empty. Embedding hosts (marimo, an SPA route swap)
dispose and recreate a widget across a rebuild; a stable
window.__nshandle survives that churn, and.logshistory outlives a single connection. Instances come and go; the handle stays. - One discoverability line, once. On first construct, emit a single
noticenaming the handle:[ns] worker observability at window.__ns — .setDebug(true), .logs, .state(), …. One line, not spam, and it tells a human/agent exactly what to type. (Keep it out of any[ns:greppable prefix so log filters don't trip over it.)
Principle 3 — Runtime debug toggle, not compile-time¶
If verbosity is fixed at construction, debugging a running page means a rebuild + reload (or, in a notebook, a kernel restart) just to turn logs on — far too slow for a pairing loop. Make it flippable at runtime:
window.__ns.setDebug(true)posts{__obs, kind:"set_debug", debug:true}to each instance's worker, which rebuilds its logger in place, and simultaneously rebuilds the main-thread loggers the registry minted. No rebuild, no reload.- The trick that makes "rebuild in place" work is a mutable logger facade: callers hold one
stable logger reference forever;
setDebugswaps the backing logger underneath them. A logger handed to a sub-component (a decode pipeline) flips too, because it holds the same facade. - Optional convenience: honor
?debug=1/?<ns>Debug=1in the URL and alocalStorage.<ns>Debugflag, read once at construct, so a plain reload can start verbose without touching code. (Read these on the main thread only — a worker realm has nolocalStorage.)
Principle 4 — A cadence heartbeat, not per-frame spam¶
Per-frame logging is a firehose that is useless at 30–60 fps and drowns the console. The useful middle ground is a periodic one-line summary emitted from the worker (and forwarded to main):
Rules that keep it clean: one line per interval (~5 s); only when something is flowing (skip, or go quiet, when idle); include the fields that actually localize bugs (transport, surface, displayed/dropped deltas, queue depth, recoveries, RTT). Emit it on the verbose (debug) tier so it appears exactly when someone has flipped debug on to watch health — additive to the always-on error/notice tiers, never competing with them.
Screenshot vs. readback — name the distinction¶
A corollary worth writing down, because the two disagree and that disagreement is the diagnosis. When a "blank / black" symptom appears, check both a pixel readback of the drawing surface and a screenshot of the composited element:
| readback | screenshot | conclusion |
|---|---|---|
| picture | black | compositing / DOM / host problem (pixels exist, never composited) |
| black | black | decode / draw problem (upstream of present) |
| picture | picture, user says black | wrong element / a cached view |
Expose a capture() snapshot provider that reads the on-screen surface where possible, so an
agent can compare window.__ns.capture() against a screenshot rather than trusting either
alone.
How the library packages this for drop-in reuse¶
@habemus-papadum/worker-observability splits along the realm boundary so each side typechecks
under its own lib (DOM vs WebWorker) and imports only what is safe there:
| import | use | contents |
|---|---|---|
.../worker |
inside the Worker | installWorkerObservability({channel, tag, debug, post}) → a stable logger, a handleMessage that consumes set_debug, and startHeartbeat(summary). |
.../main |
on the main thread | registerObservable({channel, snapshots, postToWorker}) → an ObsInstance (ingest, makeLogger, setDebug, dispose) that installs window.__<ns>; plus readDebugFlag(). |
. (root) |
either realm | pure core: RingBuffer, buildLogger/MutableLogger, the message types + isWorkerObsMessage/isMainObsMessage guards. |
Everything is injectable (the post function, the console, the clock, the timer, the registry
target object), so the whole thing is unit-testable in Node with no DOM — the ring buffer, the
registry, and the worker↔main round-trip each have tests.
Wiring it into an app is four small seams:
- Worker:
const obs = installWorkerObservability({channel, tag, debug, post: m => self.postMessage(m)}); useobs.loggereverywhere; at the top ofonmessage,if (obs.handleMessage(data)) return;obs.startHeartbeat(summary). - Main:
const obs = registerObservable({channel, snapshots, postToWorker: m => worker.postMessage(m)}); inworker.onmessage,if (obs.ingest(data)) returnbefore your own handling; mint the main-thread logger viaobs.makeLogger(tag);obs.dispose()on teardown. - Debug flag: seed it from
options.debug ?? readDebugFlag(ns)and passobs.debuginto the worker's init so URL/localStorage propagate. - Nothing else — every embedding (a plain page, a framework wrapper, a notebook widget) that
mounts the view gets
window.__<ns>for free.
The result: off by default (zero console spam in normal use), flippable from the console without a rebuild, and the worker's internals are finally readable by the exact tools an agent uses to debug them.
See also: agentic_frontend_debugging.md (the pairing workflow
and the instrument-at-the-seams rules this builds on) and the
frontend-debugging skill.