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.

Two things about a realm are worth watching in development: its projected state and sync health, and its traffic, the number that becomes your Durable Object bill. This page covers the realm-specific wiring for both. The window that renders them, the server host, the topology band and the relay are the same for actions and realms and live in the Devtools section, so this page links there rather than repeating it.

createNiceDevtools() sets up devtools for the whole app and streams the data out. To observe a realm, pass each connectRealm the tap it hands back. The panel 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 shows the live projected store, plus status / version / pending / avatar identity, and two instruments described below: the RTT sparkline and the flush strip.
  • Timeline lists 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 shows decoded realm frames per kind (hydrate, patches, write, ack, batch, …), as counts and 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 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 shows nothing rather than a 0 ms that isn’t true.
  • The flush strip draws 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.

What matters is the mix, which is why this 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, so 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). They 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 complements the onDiagnostic connect option: it can be added after connecting and takes multiple listeners. It is 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 counts encrypted frames, envelopes, handshakes and keepalives. It 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 measures traffic for the whole app and is already streamed to the window. Its per-lane and 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 gives the transport overhead (encryption, framing and the batch envelope) per payload byte.
  • egressCoalesceRatio (coalesce ×N) is how many realm frames each billable message carried. Watch it when tuning broadcastCoalesceMs and confirming the batch envelope is working.

With no devtools wired, the realm engine still keeps 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 send that same data to a server devtools host (console summaries or a live window feed), use the realm scopes: realmEngineScope(engine, "demo_board") for traffic totals and serverWireTrafficScope(engine) for 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, because an open outbound socket pins it awake, and it can’t run the console sink’s timer across hibernation. So the window dials in instead, over the DO’s ordinary WebSocket as a token-gated protocol, via createNiceDurableObjectDevtools. The full setup, safety model and hibernation caveat are in Observing Backends → a Cloudflare Durable Object.