Skip to content

Devtools & Traffic

Observing a realm: the browser realm panel, what its decoded traffic means versus the true wire cost, and the egress-message count that maps to your Durable Object bill.

A realm exposes two things worth watching in dev — its projected state + sync health, and its traffic (the number that becomes your Durable Object bill). This page is the realm-specific wiring for both. The window that renders them, the server host, the topology band, and the relay are the same across every plane and live in the Devtools section — this page links there rather than repeating it.

createNiceDevtools() builds the shared devtools cores and streams them; instrument each connectRealm with the tap it hands back. The panel that renders it lives in the devtools window, never in your page.

src/devtools.ts
import { createNiceDevtools } from "@nice-code/devtools";
export const devtools = createNiceDevtools({ topologyId: "my-app", runtime });
gameClient.ts
const realm = connectRealm(gameRealm, {
avatar: { type: "player" },
connection: realmConnection(connector),
devtools: devtools.realm("game"), // ← the observation seam (one null-check when omitted)
});

The panel shows one tab per instrumented realm (status dot, confirmed version, pending-write badge) with three views:

  • View — the live projected store, plus status / version / pending / avatar identity, and two instruments over it (below): the RTT sparkline and the flush strip.
  • Timeline — every mutation with its outcome (created → confirmed / rejected, with reject ids and rolled-back paths), every sync-health diagnostic (resync, probe_escalated, link_redialed, reown_missing, …), and status flips.
  • Traffic — decoded realm frames per kind (hydrate, patches, write, ack, batch, …), counts + bytes over rolling windows.

RTT and the flush strip — reading a client’s shape

Section titled “RTT and the flush strip — reading a client’s shape”

Beside the sync-health row, the View tab charts the two things that tell you how a client is behaving, as opposed to whether it is connected:

  • The RTT sparkline plots realm.stats over time — the smoothed confirmed round trip, its jitter (mean absolute deviation), and the sample count. It appears once there is something to plot: RTT is measured on a confirmed round trip, so a realm that only ever reads has none, and the panel says nothing rather than showing a 0 ms that isn’t true.
  • The flush strip puts one tick per recent projection flush, oldest on the left, coloured by meta.cause — the same provenance listenToPatches reports. Ticks for non-authoritative flushes are dimmed.

The mix is the point, which is why it is a strip rather than rows in the Timeline — a game client flushes at its tick rate and would bury every discrete event in the log. Read it like this:

What you seeWhat it means
Mostly optimistic (warm, dimmed)The client is predicting well ahead of what the server has confirmed — an overlay-heavy client, visible at a glance.
Mostly rebaseServer patches are landing while writes are in flight. Normal and expected for any client that writes continuously.
Mostly confirmServer truth arriving with an empty outbox — a client that mostly watches.
Hydrate ticks mid-sessionFull rebuilds. One at attach is the hydrate; more than that means re-syncs — check the Timeline for resync.
No authoritative ticks at allNothing is arriving from the server, whatever the status dot says.

Both series are bounded rings (maxSamples, default 240) and carry no leaf values — only timings, a cause, and a flag — so they stream unchanged from a shape-gated remote producer.

Construct the underlying RealmDevtoolsCore (@nice-code/realm/devtools/browser) yourself only if you aren’t using createNiceDevtools. subscribeDiagnostics(listener) on the handle is the post-hoc, multi-listener complement of the onDiagnostic connect option — handy for tests and ad-hoc telemetry (and one subscriber satisfies the unwired-diagnostics console floor).

The realm panel’s Traffic tab shows decoded realm payloads. The true wire cost — encrypted frames, envelopes, handshakes, keepalives — is measured one layer down, at the carrier, with wireTap:

client.ts
const connector = connectChannel(runtime, channel, {
// …transports, storage…
wireTap: devtools.wireTap, // ← every carrier frame, true post-encryption byte sizes
});

devtools.wireTap is one traffic core for the whole app, already streamed to the window (its per-lane, per-client breakdown, sparklines, and largest-frame list are documented on Devtools & Traffic in the window). The realm-specific reading:

  • egressMessages is your billing unit. Each outgoing WebSocket message is a Cloudflare request-count unit; the byte totals are your bandwidth.
  • The realm lane vs. the panel’s decoded totals is the transport overhead (encryption + framing + batch envelope) per payload byte.
  • egressCoalesceRatio (coalesce ×N) is “how many realm frames rode each billable message” — the number to watch when tuning broadcastCoalesceMs and confirming the batch envelope is working.

With no devtools wired at all, the realm engine exposes its own counters — scrape them from a dev-gated route:

MatchDO.ts
if (url.pathname === "/debug/traffic" && env.STAGE !== "production") {
return Response.json(this.engine.getTrafficSnapshot());
// { ingressFrames, ingressBytes, egressMessages ← billing unit, egressFrames ← pre-coalescing,
// egressBytes, egressCoalesceRatio ← the batch-envelope win, connections: [...] }
}

To contribute that same data to a server devtools host (console summaries or a live window feed), the realm scopes are realmEngineScope(engine, "demo_board") (traffic totals) and serverWireTrafficScope(engine) (per-frame samples). The host itself — its options, sinks, and how it streams to a window alongside your action domains — is documented in Observing Backends:

server.ts
import { createServerDevtoolsHost } from "@nice-code/devtools-core";
import { realmEngineScope, serverWireTrafficScope } from "@nice-code/realm/devtools/server";
createServerDevtoolsHost({ name: "match-api" })
.contribute(realmEngineScope(engine, "demo_board"))
.contributeSample(serverWireTrafficScope(engine))
.start();

A DO can’t dial out to a devtools window (an open outbound socket pins it awake) and can’t run the console sink’s timer across hibernation — so the window dials in instead, riding the DO’s ordinary WebSocket as a token-gated protocol via createNiceDurableObjectDevtools. The full setup, safety model, and the honest hibernation caveat are in Observing Backends → a Cloudflare Durable Object.