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.
Two lenses, and why they disagree
Section titled “Two lenses, and why they disagree”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.
| 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. This is what the platform transmits and bills. |
| engine · decoded payload (Server view, per realm) | inside the realm engine | The 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.
Per-client attribution
Section titled “Per-client attribution”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 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 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 pastmaxLinks(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.
Identity, derived
Section titled “Identity, derived”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 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 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. |
label | envId, plus an elided perId | The switcher and the topology band then read the way 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. |
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”| 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 no 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 listens on a well-known port, and a backend looks for it 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: { 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.
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, 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 Cloudflare Durable Object
Section titled “A Cloudflare Durable Object”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.
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:
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.
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 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.
Counters across an eviction
Section titled “Counters across an eviction”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.
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. 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
createWireDevtoolsHosttreats a missing token as a construction error.createNiceDurableObjectDevtoolscatches that case first and returns an inert host. - Payload contents never leave the process by default.
actionDomainScopestreams action id, domain, status, timings and error shape. It does not streaminput,output, or error payloads. PasslogPayloads: truedeliberately, 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.
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 reportingmeans 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 inbackendsare dialed. Attach a host, or dial the instance, and it becomes a selectable backend with its own Server view.backend · went quietmeans 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.