Skip to content

Devtools Architecture

How the three-flatland devtools connect and move per-frame telemetry between a scene and its inspecting clients — over the Broadcast Channel API, with a Web Worker, transferable ArrayBuffers, WebGPU timestamp queries, and WebCodecs.

The three-flatland devtools move per-frame renderer telemetry from a running scene to one or more inspecting clients. This page explains how a client connects and how the data flows — within the same browser tab and to another tab — and the Web platform APIs each step uses. For setup and the panel reference, see the Devtools guide.

System model — producers, consumers, and the wireMany providers, many consumers, no static wiring. They rendezvous on a shared bus, then talk on per-provider channels.PROVIDERSCHANNELSCONSUMERSDiscovery bus · flatland-debugannounce / query / gone (shared, low-noise)Flatlandsystem providerbare three.jscreateDevtoolsProvider()flatland-debug:<id-A>subscribe · ack · data · pingflatland-debug:<id-B>subscribe · ack · data · pingin-page panelsame tabdashboardanother tabremote debugger(future — WebSocket)BusTransport — the wire. Worker + BroadcastChannel (default) · InlineBusTransport fallback (CSP / no bundler worker URL).Contracts: DebugMessage taxonomy binds provider ↔ consumer; BusTransport binds producer ↔ wire. Neither side names a transport.announce / gonequery
Figure 1. A provider and its consumers. Discovery happens on a shared channel; data flows on a per-provider channel.

Every message between a provider and a consumer travels over the Broadcast Channel API . A BroadcastChannel is identified by a name. Every same-origin browsing context that constructs a channel with that name — the page itself, a Web Worker, another tab — receives every message posted to it:

// any context, same origin:
const ch = new BroadcastChannel('flatland-debug')
ch.postMessage({ type: 'provider:query' }) // fan-out to all others
ch.onmessage = (e) => { /* e.data is a structured-clone copy */ }

This one fact is the whole delivery model. Sending to the same tab and sending to another tab are the same operation — a postMessage on a named channel — because the browser delivers a copy to every context that opened that name, regardless of which tab it is in.

Connection happens in two phases, on two channels:

  • Discovery uses one shared channel, flatland-debug, that every provider and consumer opens. It carries only provider:query, provider:announce, and provider:gone.
  • Data uses a second channel that a consumer opens after it picks a provider, named for that provider: flatland-debug:<providerId>. All subscription and data traffic — subscribe, subscribe:ack, ack, data, buffer:chunk, buffer:raw, ping — flows there, off the shared discovery channel. Only that provider and its subscribers ever open this name, so it acts as a private line between them — though it is still a BroadcastChannel, not a socket, and more than one consumer (the panel and the dashboard) can share it.

A provider runs in the inspected page and produces telemetry. A consumer reads it — the in-page devtools panel, a dashboard in another tab, or a future remote client. There is no static wiring between them; they find each other through discovery.

Discovery, subscription, and livenessMany consumers, many providers, no static wiring. A rendezvous protocol pairs them; a heartbeat detects the dead.Consumer · devtools clientProvider · DevtoolsProviderDISCOVERYprovider:query · broadcast on flatland-debugprovider:announce → consumer subscribes on the first oneno announce yet? consumer retries query — backoff 500 ms → 5000 ms, up to 10×SUBSCRIPTIONsubscribe { features } · per-provider channelsubscribe:ack { env }provider now collects only the subscribed features —collapse a panel, the work stopsSTEADY STATEdata · every STATS_BATCH_MS = 250 msack · every ACK_INTERVAL_MS = 1000 msping · when idle > IDLE_PING_MS = 2000 msLIVENESS & TEARDOWNno server msg for SERVER_LIVENESS_MS =5000 ms → presume gone, re-subscribedrop consumer if no ack for ACK_GRACE_MS= 3000 ms (2 missed acks)provider:gone · on teardown
Figure 2. The connection handshake and the heartbeat that follows, with its timeouts.

A connection is a handshake, then a subscription. Each step is a postMessage on one of the two channels:

  1. Announce. On start(), the provider opens flatland-debug and posts provider:announce with its id. When activated through createDevtoolsProvider(), the provider class is loaded with a dynamic import(), so start() runs asynchronously and a frame or two may pass before the provider is live on the bus.
  2. Query. A consumer opens flatland-debug, posts provider:query, and listens. The first provider:announce it receives triggers subscription immediately — there is no fixed collection window. Selection prefers a user provider over a system one, first-announced as the tiebreak. If no announce arrives, the consumer retries provider:query with exponential backoff: 500 ms, doubling each attempt, capped at 5000 ms, for up to 10 attempts. A provider that starts after the retries are exhausted still connects on its own provider:announce.
  3. Subscribe. The consumer opens the chosen provider's data channel, flatland-debug:<id>, and posts subscribe with payload { id, features, registry?, buffers? }. registry is an optional name filter: omitted samples every entry, [] samples none (metadata still ships so the UI can list them), a named list narrows to those. buffers maps entry names to { mode: 'thumbnail' | 'stream', thumbSize? }: thumbnail requests a downsampled preview (default 256 px on the long edge), stream a VP9-encoded full-size stream. An entry absent from the map, or an omitted buffers, means metadata only with no GPU readback.
  4. Acknowledge. The provider replies subscribe:ack and begins collecting only those features. The SubscriberRegistry holds the union of every consumer's feature set, and each collection site checks it, so nothing is measured that no one is watching.

After that, both sides run a heartbeat so each can detect the other going away: the provider posts data on each 250 ms flush that produced output, and a bare ping after IDLE_PING_MS (2000 ms) of silence; the consumer sends ack every ACK_INTERVAL_MS (1000 ms). The provider drops a consumer after ACK_GRACE_MS (3000 ms) without an ack; the consumer re-subscribes after SERVER_LIVENESS_MS (5000 ms) without any message from the provider.

The zero-allocation transport cycleOne pool buffer makes the round trip every flush. The render thread avoids theexpensive structured clone; the worker runs it, once per subscriber.RENDER THREAD · producerendFrameone sample → pre-allocated ringno allocationflush @ 250 ms (STATS_BATCH_MS)acquireMedium() → memcpy ring snapshottyped-array views over the pool bufferworker.postMessage(msg, [poolBuf])transfer — zero-copyWORKER · bus offloadbc.postMessage(msg)StructuredSerialize — the clone runson the worker, not the render threadpostMessage({__release__, buf}, [buf])bounce pool buffer back to producerCONSUMERS · many, per-provider channelBroadcastChannel delivers to each subscriberits own structured-clone copydecode Int16 / Uint16 / Uint32→ Float32 rings, keyed by startFramepool buffer reused every flush — never reallocatedbuffer pool — allocated once in the worker, transferred to the producer at boot: small 4 KB ×8 · medium 256 KB ×4 (per-flush data packet) · large 16 MB ×4 (texture readback)
Figure 3. The producer's send path: fill a pooled buffer on the render thread, transfer it to a worker, broadcast from there, and transfer it back.

Once a consumer is subscribed, the provider samples once per frame. Every 250 ms _flush() runs, but it posts a data message on flatland-debug:<id> only when at least one subscribed feature produced output that tick; otherwise it may post a bare ping for liveness. The Broadcast Channel API delivers a copy to every context that opened that channel — so the same postMessage reaches the in-page panel in the same tab and a dashboard in another tab, with no difference in code or path. A consumer's side is just:

const ch = new BroadcastChannel('flatland-debug:' + providerId)
ch.onmessage = (e) => applyDelta(e.data) // decode and render

The producer's side is where the work is, and it is the reason a Web Worker is involved. BroadcastChannel.postMessage runs the structured clone algorithm  synchronously on the calling thread, copying every typed array in the message. Run on the render thread, that copy is charged to the frame budget. So the provider hands the broadcast to a worker and moves one buffer between them using transferable objects :

  1. On the render thread, the flush fills a pooled ArrayBuffer with the frame's samples and builds a data message whose typed-array fields are views over it.
  2. worker.postMessage(msg, [poolBuf]) transfers the buffer to the worker (zero-copy on this hop).
  3. The worker calls bc.postMessage(msg). The structured clone runs here, on the worker, once per subscriber. This is the step that crosses into every other context — same tab or not.
  4. The worker transfers the buffer back with a { type: '__release__', buf } message; the next flush reuses it.

The pool is a set of ArrayBuffers in three tiers — 4 KB × 8, 256 KB × 4 (the per-flush data packet), and 16 MB × 4 (texture readback) — allocated once in the worker and transferred to the producer at boot, so the render thread does no per-frame allocation in steady state. Until the __pool_init__ seed messages arrive, acquire* returns one-off ArrayBuffers; InlineBusTransport allocates per call and has no real pool. If a worker cannot be created — no Worker global, a bundler that does not resolve the new URL('./bus-worker', import.meta.url) pattern, or a blocking Content-Security-Policy — InlineBusTransport posts to the BroadcastChannel directly on the producer thread instead, trading the offload for a working fallback.

Both implementations sit behind one interface, so nothing above them names a transport:

export interface BusTransport {
acquireSmall(): ArrayBuffer // 4 KB
acquireMedium(): ArrayBuffer // 256 KB — the per-flush data packet
acquireLarge(): ArrayBuffer // 16 MB — texture pixel readback
post(msg: DebugMessage, bufs?: ArrayBuffer[]): void
convert(req: ConvertRequest, poolBuf: ArrayBuffer): void
readonly codecSupported: boolean | null
releaseUnused(buf: ArrayBuffer): void
poolStats(): { smallFree: number; mediumFree: number; largeFree: number }
dispose(): void
}
Frame identity as a correlation keyrenderer.info.frame attributes asynchronous, coalesced GPU timings to the ring slot of the frame that issued them.PRODUCER · one sample per frameframe Ncpu ✓ gpu ✓frame N+1cpu ✓ gpu ~frame N+2cpu ✓ gpu ~frame N+3cpu ✓ gpu ✓frame N+4cpu ✓ gpu ~gpu ~ = last resolved value (stairstep, not a zero)GPU timestamp resolves — async, out of ordergetTimestampFrames('render') → [ …, N ]last id = the frame whose duration just resolved_tjsFrameToIdxN → slot 0N+1 → 1 · N+2 → 2 …resolved gpuMs → tracked slots (coalesced)flush every 250 ms → transport → consumer receives the batchCONSUMER · append samples to rings, averagedata batch: [ startFrame = N · count = 5 · fps[] · cpuMs[] · gpuMs[] · … ]appended in order · batch mean → readout · startFrame unused by the client todaysample 0sample 1sample 2sample 3sample 4
Figure 4. Asynchronous GPU timings are attributed to the ring slot of the frame that issued them; the consumer appends the batch's samples to rings and averages.

The data being sent is a batch of per-frame samples, and each sample is keyed by the provider's own _frame counter, incremented once per endFrame and captured into the batch as startFrame. That counter is distinct from renderer.info.frame, three.js's render counter, which the collector uses only to map resolved WebGPU timestamp durations back to a ring slot via _tjsFrameToIdx. A frame index rather than wall-clock time is used because two data sources arrive out of step with the frame they belong to.

WebGPU timestamp queries resolve asynchronously and out of order. three.js exposes backend.getTimestampFrames('render'), which returns the frame ids in the batch that just resolved; the last is the frame whose duration is now in renderer.info.render.timestamp. The collector keeps a Map from frame id to ring slot (_tjsFrameToIdx). three.js coalesces concurrent resolveTimestampsAsync calls into one promise and returns only the last resolved frame's duration, so intermediate frames lose their individual value. When the promise settles, the collector writes that duration into every ring slot still tracked in _tjsFrameToIdx, then clears the mappings at or before the resolved frame id. Slots drained before their resolve arrives keep the forward-filled value rather than a zero.

Batching adds the second offset. Samples are written once per frame into pre-allocated typed arrays as scaled integers — fps as Int16Array × 10, cpuMs and gpuMs as Uint16Array × 100, the counts as Uint32Array — and flushed together. The batch carries a startFrame index and a count. On receipt the consumer appends all count samples into per-field Float32Array rings and computes a batch mean for each field's scalar readout; startFrame is present in the payload but not read by the current client — it is reserved for future frame-accurate reconstruction. The producer-side ring holds 240 samples and overflow drops the newest.

The provider does not resend a value that has not changed. Each feature in a data message is a delta against the packet it last sent, so a packet carries only what moved since the previous tick, and the consumer reprojects the full picture by applying the delta to the state it already holds. Field by field:

  • absent — unchanged; the consumer keeps its current value.
  • null — cleared; the consumer resets the field.
  • present — replaced with the new value.

On the provider side each feature tracks changes its own way. EnvCollector keeps a _prev snapshot and diffs it field by field. DebugRegistry and DebugTextureRegistry track per-entry version / lastEmittedVersion counters plus a shape flag (full sample vs. metadata-only), and skip entries where both match. BatchCollector compares a monotonic _version against _lastEmittedVersion and skips the drain when they are equal. StatsCollector always ships fresh typed-array samples; only heapLimitMB is deltaed, sent once then omitted. On the consumer side each data message is merged into an accumulated DevtoolsState, so a panel renders from its own running copy rather than any single packet. When a consumer subscribes, each collector's resetDelta() runs so its next batch is a full snapshot, not a delta against state the consumer never received.

The per-frame stats arrays are new samples every batch and are always carried; the delta matters most for the structured features — the registry, buffer metadata, environment, and batch list — where most fields are unchanged from one packet to the next.

The producer and its offload worker exchange structured objects over Worker.postMessage, not a binary frame. The render thread sends two kinds of message:

  • Control{ type: '__init__', channelName } to boot the worker, and { type: '__convert__', ...req, __poolBufs } to hand over raw pixels for RGBA8 conversion and optional VP9 encoding.
  • Data flushes — a DebugMessage object whose typed-array fields are views into a transferred pool ArrayBuffer, tagged with __poolBufs so the worker knows which buffers to return after bc.postMessage has serialised the payload.

The worker replies with { type: '__pool_init__', tier, bufs } to seed the pool, { type: '__release__', buf } after each broadcast to return a pool buffer, and { type: '__codec_support__', vp9 } once the VP9 probe settles. The hand-rolled binary frame in bus-frame.ts is not wired to any live transport; it is the planned encoding for the future remote transport, described below.

Compressed buffer streaming with WebCodecsRaw RGBA8 readbacks are too large to ship per frame, so the buffer channel carries video — each frame still tagged with its render frame.endFrame readbackRenderTarget / DataTexturelarge 16 MB pool bufferworker __convert__convertToRGBA8(pixelType, display)VP9 VideoEncodervp09.00.10.08 · realtime4 fps · keyframe every 2 sbuffer:chunk{ frame, keyFrame,codec, data }BroadcastChannelper-provider data channelconsumerVideoDecoder → VideoFrame→ panelevery chunk carries frame — bound to the render frame (Figure 2)fallback — buffer:raw: when the subscription is not in stream mode, or codecSupported is false (WebCodecs / VP9unavailable), the same __convert__ path broadcasts display-ready RGBA8 { frame, width, height, data } instead ofencoding. codecSupported is probed once at worker boot: null until it settles, then true / false.
Figure 5. A texture readback encoded with the WebCodecs VideoEncoder and sent as buffer:chunk, with a buffer:raw fallback.

Inspecting a RenderTarget or DataTexture means sending its pixels. A raw RGBA8 readback is large, so when a subscription is in stream mode and the codec is available, the worker converts the pixels to RGBA8 and encodes them with the WebCodecs  VideoEncoder:

encoder.configure({
codec: 'vp09.00.10.08', // VP9
width, height,
framerate: 4,
latencyMode: 'realtime',
bitrateMode: 'quantizer',
})

Each EncodedVideoChunk is broadcast as a buffer:chunk message with the render frame it was read on attached:

payload: { name, frame, capturedAt, width, height, pixelType, display, keyFrame, codec, data }

On the consumer side, buffer:chunk is delivered to registered chunk listeners; a listener waits for a keyframe before feeding chunks to a VideoDecoder. Codec support is probed once at worker boot and reported as codecSupported (null until it settles, then true or false). When codec support is false or the subscription is not in stream mode, the worker broadcasts buffer:raw with display-ready RGBA8 instead; the consumer writes those pixels into DevtoolsState.buffers[name].pixels and the panel paints them with putImageData. When the inline transport is active it produces buffer:raw directly on the producer thread, bypassing the worker. Keyframes are forced on a resolution change and at least every two seconds.

Extension to remote transportsA WebSocketTransport is a new adapter under the same interface. Everything above the seam is unchanged; only the wire differs.ABOVE THE SEAM — identical in bothframe-identity correlation · per-frame sampling & 250 ms batchingdiscovery / subscription / liveness · DebugMessage taxonomyBusTransport interface — the swap pointproducer and consumer are written against this; neither names a transportWorkerBusTransport — todayWorker + BroadcastChannel (same origin)native structured-clone · verbose field namesInlineBusTransport when no workerWebSocketTransport — roadmapWebSocket to another machinekey compression + msgpack / CBOR at the wireVP9 chunks are already wire-readySerialization and compression live in the adapter, at the wire boundary — everything above the seam is oblivious.
Figure 6. A WebSocketTransport is another BusTransport implementation; everything above the interface is unchanged.

BroadcastChannel reaches other tabs on the same origin, but not another machine. Today's BusTransport is the producer-side pool-and-offload interface — buffer acquisition, post, and convert over the current BroadcastChannel path — not a full discovery or client-transport protocol. A remote transport would sit behind the same interface for the broadcast step, but would also need a serialization layer and a client-side bridge, because BroadcastChannel's native structured clone is unavailable over a socket. The connection handshake, the frame-keyed sampling, and the DebugMessage taxonomy are above the interface and would not change. A byte transport is where key compression and a binary encoding such as msgpack or CBOR would apply; the same-origin BroadcastChannel path uses structured clone, so field names stay verbose there.

The binary wire format for that byte transport is defined in bus-frame.ts and is not yet wired. Each frame is a single ArrayBuffer: a 16-byte header — usedBytes (u32 @0), type (u32 @4, from the BUS_TYPE enum), and Date.now() split into tsLow/tsHigh (u32 @8 and @12) to avoid a BigInt — followed by TLV feature sections (featureId u32, sectionBytes u32, then the bytes), one per subscribed feature.

Binary frame layout — planned wire protocolThe planned byte transport frames each message as one ArrayBuffer: a 16-byte header, then TLV sections.usedBytesuint32 · @0typeuint32 · @4tsLowuint32 · @8tsHighuint32 · @12payload@16 · TLV sectionsFixed 16-byte header — tsLow / tsHigh split Date.now() into two uint32 so the framecarries no BigInt. The writer jumps straight to offset 16 for the payload.data payload — one TLV section per subscribed feature (repeats: stats · batches · registry · env)featureIduint32sectionBytesuint32raw payload — per-feature header + typed-array bytesstats section payload — scaled fixed-point, one sample per frame, keyed by startFramestartFrameuint32countuint32fpsInt16 ×10cpuMs · gpuMsUint16 ×100drawCalls · trianglesgeometries · texturesUint32 ×1BUS_TYPE (frame.type, uint32 @4): DATA 1 · SUBSCRIBE 2 · SUBSCRIBE_ACK 3 · ACK 4 · UNSUBSCRIBE 5 · PING 6 PROVIDER_ANNOUNCE 7 · PROVIDER_QUERY 8 · PROVIDER_GONE 9 · POOL_INIT 10 · POOL_RELEASE 11
Figure 7. The planned binary wire format in bus-frame.ts, not yet wired to a live transport.