Observing Backends
Attach the devtools window to a server — including a Cloudflare Durable Object — and read per-client wire traffic: which client costs what, 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 is about the other end of the wire: getting a backend into the devtools window, and reading its traffic per connected client, because that is the unit your bill is denominated in.
Two lenses, and why they disagree
Section titled “Two lenses, and why they disagree”A backend reports the same traffic twice, measured at different layers. Both are correct. They are labelled in the UI so the difference never reads as a bug.
| Lens | Where it measures | What it counts |
|---|---|---|
| wire · post-encryption bytes (Traffic sidebar) | the carrier | Every frame on every lane (handshake, keepalive, action, realm, http), sized after encryption — what the platform transmits and bills. |
| engine · decoded payload (Server view, per realm) | inside the realm engine | The realm lane only, sized before the crypto envelope — plus egress in messages (the per-message billing unit) vs frames, and the coalesce ratio batching won you. |
The wire lens is always larger: it carries the envelope the engine never sees. If you are optimizing cost, read egress messages from the engine lens and egress bytes from the wire lens.
Per-client attribution
Section titled “Per-client attribution”Every tapped frame carries a linkId — the coordinate of whoever is on the other end. On a server that is the connected client; on a client it is the backend it dialed. So the same clients table appears on both sides of a wire, answering the same question from each end:
CLIENTS · LAST 5Mclient in in bytes out out bytesenvId[web_app]…insId[i1] 412 21.1 KB 418 44.9 KBenvId[web_app]…insId[k9] 91 4.8 KB 94 9.7 KB(handshaking) 8 3.2 KB 8 2.6 KBtotal 511 29.1 KB 520 57.2 KBTwo reserved rows keep the arithmetic honest, so the table always sums to the headline totals:
(handshaking)— frames tapped before a peer identity existed. A server cannot know who is connecting until the handshake binds them, so those bytes are real but unattributable. (A client stamps its handshake, because it knows its peer before it sends the first byte.)(other links)— links pastmaxLinks(default 200), folded together. A public server must not grow one counter per socket it has ever seen.
Nothing here captures payload contents. Sizes and counts only, by design.
Identity, derived
Section titled “Identity, derived”Every producer stamps an identity envelope on its frames. All of it comes off the runtime coordinate you already hand over, and none of it is worth restating:
| Envelope field | Where it comes from | Why |
|---|---|---|
coordEnvId | runtime.coordinate.envId | The 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. |
clientId | frontend: insId · server: envId::perId · DO: do_<perId> | Frontends key by instance (two tabs are two clients). Backends key by identity, so a restarted dev server replaces its entry rather than leaving a ghost beside a fresh one. A DO’s persistent id survives hibernation wakes. |
label | envId, plus an elided perId | The switcher and the topology band read like the coordinates do. Override with name. |
stage → env | you, explicitly | Deployment stage is where it runs; envId is which runtime it is. They change for different reasons, so neither can be derived from the other. |
That asymmetry between insId and perId is not a devtools invention — it is the distinction RuntimeCoordinate already draws, reused rather than re-stated.
Which backends can serve several topologies
Section titled “Which backends can serve several topologies”| Producer | How it reaches a window | Topologies |
|---|---|---|
| Web app | BroadcastChannel + a relay room | Exactly one. A frontend is one app. |
| Long-lived server | dials out, one relay room per socket | Many — pass topologyId: [...]. |
| Durable Object | the window dials in | Many, with zero configuration. It has no topologyId; whoever dials in observes it. |
Attaching a backend
Section titled “Attaching a backend”A long-lived server (Bun, Node, …)
Section titled “A long-lived server (Bun, Node, …)”It dials the relay itself and appears in the switcher as its own client. There is nothing to configure: the relay lives on a well-known port, and a backend simply looks there.
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, // 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(runtime, channel, { storage, carriers, wireTap: devtools.wireTap });That is the whole setup: one call, the exact mirror of the frontend’s createNiceDevtools(), and the same three seams behind it.
The relay sink defaults to ws://localhost:5199 — 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 the moment 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 backend must always name its own relay — localhost means nothing there — though the whole thing is inert under NODE_ENV === "production" regardless.
createServerDevtoolsHost from @nice-code/devtools-core is the seam underneath, if you need to compose scopes the two records don’t cover.
One backend, several apps
Section titled “One backend, several apps”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 — a backend is a room member, not a router — and a clear issued from one window is addressed to one client, so it cannot disturb the other. Costs one WebSocket per topology, to the same local relay.
A Cloudflare Durable Object
Section titled “A Cloudflare Durable Object”A DO is different, and the difference is not cosmetic. Its entire cost model is hibernation. A dial-out relay sink would hold an outbound socket open, pinning the DO awake — billing you for the privilege of measuring its idle cost, and reporting numbers from a DO that never sleeps.
So the direction is inverted: the window dials in — a plain wire connection to the DO’s ordinary WebSocket endpoint (the same one its clients use), where devtools rides the wire mux as its own token-gated protocol. No separate dev route, no socket tagging: the DO’s forwards stay exactly as they were, and a hibernation wake needs no re-adoption (the protocol re-admits a surviving socket lazily, on its next frame).
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, // 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, { runtime, 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; no ActionRuntime 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. 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 routinely wires its devtools before its engines exist. It is resolved on window attach, long after the constructor returned, so either construction order is correct.
Point the window at the DO’s WebSocket endpoint — from the dev server, which is the only place that can hold the token without publishing it:
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, and a production build never sees it — the plugin is apply: "serve".
A backend with no token is skipped rather than dialed. Without one 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 window’s dial is owned by the wire client — reconnection is its keep-alive ladder, and every link-up (first attach, redial, or a re-admission after the DO wakes) triggers an immediate hello, so a rewoken backend re-attaches at once instead of waiting out the heartbeat.
The hibernation caveat, stated plainly
Section titled “The hibernation caveat, stated plainly”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 a thing that only exists when observed, and it is why this is a dev tool.
Detached, it costs exactly nothing: no timers, no sockets, no projection work. A started-but-unattached bridge arms zero intervals — a single pending timer would prevent hibernation forever.
Counters across an eviction
Section titled “Counters across an eviction”A DO’s in-memory counters die with it. Two honest options:
- Default: they restart, and the MAX view says so —
⟳ counters reset (host woke). Nothing pretends to be a lifetime total. persistCounters: true: a compact rollup (lifetime totals + per-client totals, not the 1-second ring) is flushed when the last observer detaches and restored on wake. The rolling windows and the graph still restart — only the all-time numbers continue. Off by default: it is a storage write on a path that otherwise costs nothing, and traffic between the last detach and an eviction is not captured.
Safety
Section titled “Safety”A server scope can be observed over a socket, so the defaults assume it will be:
stageis required, and"production"disables everything. workerd has noNODE_ENVto infer from, and a devtools endpoint that fails open is not a devtools endpoint. (An absenttokenis also fail-closed: it can only ever close the route, never open it — which is why the wrapper treats it as “devtools off” rather than a crash.)- The token is compared in constant time, and a missing token is a construction error, not a warning.
- Payload contents never leave the process by default.
actionDomainScopestreams action id, domain, status, timings and error shape — notinput,output, or error payloads. PasslogPayloads: truedeliberately, in dev, when you need the values.
This is a localhost / wrangler dev feature. Exposing it to a deployed environment needs short-lived authenticated sessions and a build-time strip, neither of which exists yet.
Reading the topology
Section titled “Reading the topology”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— never heard from. Either no devtools host is attached to it, or it is a per-instance Durable Object (one per game, match, room) whose endpoint this window was never told to dial, because only the instances named inbackendsare dialed. Attach a host, or dial the instance, and it becomes a selectable backend with its own Server view.backend · went quiet— 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 heals 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 it is not a bug. A producer’s bridge stops projecting after 45s without a hello from any window — that detachment is exactly 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 re-hellos the instant its page becomes visible again, 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, blue coming back is its ingress — with width, opacity and speed scaling logarithmically with the rate, so a keepalive trickle and a cursor storm both read honestly.