Skip to content

Observing Backends

Attach the devtools window to a server, including a Cloudflare Durable Object, and see what each connected client costs you in true billable bytes.

A frontend’s devtools tell you what your app sent. They cannot tell you what your server paid for it. This page covers the server side: how to get a backend into the devtools window, and how to read its traffic per connected client, because that is how your bill is counted.

A backend reports the same traffic twice, measured at two different layers. Both are correct, and the UI labels each one so the gap never looks like a bug.

LensWhere it measuresWhat it counts
wire · post-encryption bytes (Traffic sidebar)the carrierEvery frame on every lane (handshake, keepalive, action, realm, http), sized after encryption. This is what the platform transmits and bills.
engine · decoded payload (Server view, per realm)inside the realm engineThe realm lane only, sized before the encryption envelope is added. Also egress in messages (the per-message billing unit) vs frames, and the coalesce ratio batching won you.

The wire lens is always larger, because it includes the envelope the engine never sees. If you are optimizing for cost, read egress messages from the engine lens and egress bytes from the wire lens.

Every tapped frame carries a linkId, which identifies whoever is on the other end: the connected client on a server, the dialed backend on a client. So the same clients table appears on both sides of a connection, and answers the same question from each end:

CLIENTS · LAST 5M
client in in bytes out out bytes
envId[web_app]…insId[i1] 412 21.1 KB 418 44.9 KB
envId[web_app]…insId[k9] 91 4.8 KB 94 9.7 KB
(handshaking) 8 3.2 KB 8 2.6 KB
total 511 29.1 KB 520 57.2 KB

Two reserved rows make sure the table always adds up to the headline totals:

  • (handshaking) holds frames tapped before a peer identity existed. A server cannot know who is connecting until the handshake identifies them, so those bytes are real but cannot be attributed. A client does label its handshake frames, because it knows its peer before it sends the first byte.
  • (other links) holds every link past maxLinks (default 200), added together. A public server must not grow one counter per socket it has ever seen.

Nothing here captures payload contents. It records sizes and counts only, by design.

Every producer (an app or server that reports to devtools) labels its frames with an identity. All of it is derived from the runtime coordinate you already pass in, so you never restate it:

Envelope fieldWhere it comes fromWhy
coordEnvIdruntime.coordinate.envIdThe identity a client names as its peer. The topology joins each frontend’s realm wire to the backend advertising it. A realm-id join alone is ambiguous when two backends host the same realm.
clientIdfrontend: insId · server: envId::perId · DO: do_<perId>Frontends are keyed by instance, so two tabs are two clients. Backends are keyed by identity, so a restarted dev server replaces its entry instead of leaving a ghost beside it. A DO’s persistent id survives hibernation wakes.
labelenvId, plus an elided perIdThe switcher and the topology band then read the way the coordinates do. Override with name.
stage → envyou, explicitlyDeployment stage is where it runs; envId is which runtime it is. They change for different reasons, so neither can be derived from the other.

The insId / perId difference is not a devtools invention. It is the distinction RuntimeCoordinate already draws, reused here.

Which backends can serve several topologies

Section titled “Which backends can serve several topologies”
ProducerHow it reaches a windowTopologies
Web appBroadcastChannel + a relay roomExactly one. A frontend is one app.
Long-lived serverdials out, one relay room per socketMany — pass topologyId: [...].
Durable Objectthe window dials inMany, with no configuration. It has no topologyId; whoever dials in observes it.

It dials the relay itself and appears in the switcher as its own client. There is nothing to configure: the relay listens on a well-known port, and a backend looks for it there.

backend/devtools.ts
import { createNiceServerDevtools } from "@nice-code/devtools/server";
import { DEVTOOLS_TOPOLOGY } from "my-app-shared";
export const devtools = createNiceServerDevtools({
topologyId: DEVTOOLS_TOPOLOGY, // the relay room — the same id your frontend passes
runtime: { coordinate: identity }, // identity: label, clientId and coordEnvId all derived
realms: { match: engine }, // one scope per realm engine
domains: [act_root], // the ROOT action domains to observe
});
serveChannel(channel, { identity, storage, carriers, wireTap: devtools.wireTap });

This one call is all the setup. It mirrors the frontend’s createNiceDevtools(): realms, action domains and wire traffic are all wired from here.

The relay sink defaults to ws://localhost:5199. That is where niceDevtools() starts a relay, and where bunx @nice-code/devtools-relay listens. A backend usually boots before anyone opens devtools, so its first dials are refused. It keeps retrying quietly on a backoff and joins as soon as a relay appears, with no restart and no line in your log.

Pass relayPort when the relay is on another port, or relayUrl when it is on another machine. A deployed (staging/dev) backend doesn’t use the local relay at all. Pass it a session, and the same call dials an authenticated short-lived room on a deployed session relay instead, with the identity still derived from runtime; see Remote Devtools Sessions. All of this defaults to inert under NODE_ENV === "production"; only an explicit enabled: true overrides that.

createServerDevtoolsHost from @nice-code/devtools-core is the lower-level building block underneath, if you need to compose scopes the two records don’t cover.

A service shared by a web app and an admin console belongs to both devtools topologies. Name them all:

createNiceServerDevtools({ topologyId: ["web-app", "admin-app"], runtime, /* … */ });

It joins both relay rooms and appears in both windows. The two apps’ frontends stay invisible to each other, because a backend is a room member, not a router. A clear issued from one window is addressed to one client, so it cannot disturb the other. The cost is one WebSocket per topology, to the same local relay.

A DO is different in a way that matters. Its whole cost model is hibernation. A dial-out relay sink would hold an outbound socket open and pin the DO awake. You would pay to measure its idle cost, and get numbers from a DO that never sleeps.

So the direction is reversed: the window dials in. It connects to the DO’s ordinary WebSocket endpoint, the same one its clients use, and devtools shares that connection as its own token-gated protocol on the wire mux. There is no separate dev route and no socket tagging, so the DO’s forwards stay exactly as they were. A hibernation wake needs no re-adoption either: the protocol re-admits a surviving socket lazily, on its next frame.

MatchDO.ts
import { createNiceDurableObjectDevtools } from "@nice-code/devtools/server";
import { serveDurableObject } from "@nice-code/action/platform/cloudflare";
constructor(ctx: DurableObjectState) {
super(ctx, env);
const devtools = createNiceDurableObjectDevtools(ctx, {
runtime: { coordinate: identity }, // identity: label, clientId and coordEnvId all derived
stage: "development", // required — see below
token: env.DEVTOOLS_TOKEN, // unset ⇒ inert. No token, no protocol, no bridge, no cost.
realms: { match: () => this.engine }, // a getter: resolved when a window attaches
persistCounters: true, // optional: lifetime totals survive an eviction
});
// Wires the wire tap and registers the devtools protocol beside the app's own.
this.server = serveDurableObject(ctx, channel, { identity, devtools });
}

A realm-only DO does the same on the wire host: serveWireDurableObject(ctx, { identity, protocols, devtools }), with runtime: { coordinate: identity }. The coordinate source is structural, so no ActionRuntime is needed.

Note there is no topologyId, and no token ? … : undefined conditional. An absent token means inert, which is still fail-closed: an unset token can only ever close the door, never open it. And because the window dials in, any number of apps’ windows may observe one shared DO with no configuration at all.

realms takes a getter because a DO often wires its devtools before its engines exist. The getter is resolved when a window attaches, long after the constructor has returned, so either construction order works.

Point the window at the DO’s WebSocket endpoint from the dev server. It is the only place that can hold the token without publishing it:

vite.config.ts
import { niceDevtools } from "@nice-code/devtools-vite";
export default defineConfig(({ mode }) => {
// Empty prefix, so an un-prefixed name is visible here. A `VITE_`-prefixed token would be inlined
// into the app's bundle, and a devtools endpoint that fails open is a data leak, not a dev tool.
const env = loadEnv(mode, import.meta.dirname, "");
return {
plugins: [
react(),
niceDevtools({
backends: [{ url: "ws://localhost:8787/match/ws", token: env.DEVTOOLS_DO_TOKEN }],
}),
],
};
});

The plugin injects it into the devtools window page it serves, and nowhere else. An app tab never receives it. A production build never sees it, because the plugin is apply: "serve".

A backend with no token is skipped rather than dialed. Without a token the DO registers no devtools protocol at all, so a dial could never be admitted. Staying away leaves it an honest “not reporting” node in the topology. The wire client owns the window’s dial, so reconnection is its keep-alive ladder. Every link-up (first attach, redial, or re-admission after the DO wakes) triggers an immediate hello, so a rewoken backend re-attaches at once instead of waiting out the heartbeat.

While a devtools window is attached, the bridge holds a heartbeat timer, and the window’s own heartbeat re-wakes an evicted DO. Attached window ≈ DO awake. That is inherent to observing something that only exists when observed, and it is why this is a dev tool.

Detached, it costs nothing: no timers, no sockets, no projection work. A started-but-unattached bridge arms zero intervals, because a single pending timer would prevent hibernation forever.

A DO’s in-memory counters die with it. You have two options:

  • Default: they restart, and the MAX view says so with ⟳ counters reset (host woke). Nothing pretends to be a lifetime total.
  • persistCounters: true: a compact rollup is flushed when the last observer detaches and restored on wake. It holds lifetime and per-client totals, not the 1-second ring. The rolling windows and the graph still restart; only the all-time numbers continue. It is off by default, because it adds a storage write to a path that otherwise costs nothing, and traffic between the last detach and an eviction is not captured.

A server scope can be observed over a socket, so the defaults assume it will be:

  • stage is required, and "production" disables everything. workerd has no NODE_ENV to infer from, and a devtools endpoint that fails open is not a devtools endpoint. An absent token is also fail-closed: it can only ever close the route, never open it. That is why the wrapper treats it as “devtools off” rather than a crash.
  • The token is compared in constant time. Only the lower-level createWireDevtoolsHost treats a missing token as a construction error. createNiceDurableObjectDevtools catches that case first and returns an inert host.
  • Payload contents never leave the process by default. actionDomainScope streams action id, domain, status, timings and error shape. It does not stream input, output, or error payloads. Pass logPayloads: true deliberately, in dev, when you need the values.

This page covers the localhost / wrangler dev case. The same dial-in mechanism also reaches a deployed staging/dev backend, over wss://, with the token in your deploy config. See Inspecting Live Deployments. It stays inert under stage: "production" regardless.

Backends and clients are joined on the peer each client dials, not on a shared realm id. Two backends may serve the same realm (a durable board on a DO beside an ephemeral one on a plain server), and guessing draws the traffic on the wrong wire.

A backend this window is not hearing from appears as a dashed placeholder node. It is not a ghost: the flow animating beside its wire is measured at the client, so those bytes are real. Click it and it tells you which of two things is true. The badge says so too:

  • backend · not reporting means it was never heard from. Either no devtools host is attached to it, or it is a per-instance Durable Object (one per game, match or room) whose endpoint this window was never told to dial. Only the instances named in backends are dialed. Attach a host, or dial the instance, and it becomes a selectable backend with its own Server view.
  • backend · went quiet means it was reporting and stopped. Either the backend went down, or the devtools window sat in the background long enough for its bridge to detach. The second case fixes itself: bring the window to the front and the backend returns within a second.

That second state is worth understanding, because it is normal and not a bug. A producer’s bridge stops projecting after 45s without a hello from any window. That detachment is what lets a Durable Object hibernate instead of billing you to watch itself idle. A window whose page is hidden has its timers throttled by the browser to roughly once a minute, so its heartbeat cannot hold the bridge open, and after 30s of silence the window forgets the producer. Neither side is wrong. Recovery is an event, not a timeout: the window sends hello again the instant its page becomes visible, and every bridge re-attaches and replays over the socket it never closed.

Flow lanes run beside each wire. Green marching toward the backend is the client’s egress, and blue coming back is its ingress. Width, opacity and speed scale logarithmically with the rate, so a keepalive trickle and a cursor storm are both readable.