Skip to content

Devtools

Chrome’s network panel shows nothing useful for a WebTransport session: no frames, no streams. @transport-io/devtools is a panel in the page that shows both, and the drops that stats() counts, by event.

The chat example's agents page with the devtools panel open along the bottom. The page's header reads open streams 2 and both agents are streaming. The panel's bar reads connected on webtransport with its counters at zero, and below it a list of frames: responses on streams 1 and 2 with a word in each preview, a close row for each of them, then an open and a request row for streams 3 and 4. A side column lists two open streams, 3 and 4, and no drops.The chat example's agents page with the devtools panel open along the bottom. The page's header reads open streams 2 and both agents are streaming. The panel's bar reads connected on webtransport with its counters at zero, and below it a list of frames: responses on streams 1 and 2 with a word in each preview, a close row for each of them, then an open and a request row for streams 3 and 4. A side column lists two open streams, 3 and 4, and no drops.

The panel under the chat example’s agents page, over real QUIC: two streams on one session, restarted, so the list has their close rows and the side column the two that replaced them. Every row is a frame that crossed the wire.

Terminal window
npm install @transport-io/devtools

You mount it; nothing loads it for you, in any environment.

contract.ts
import { defineContract, type MapOf, reliable, unreliable } from 'transport-io'
export const contract = defineContract({
chat: reliable<{ from: string; body: string }>(),
cursor: unreliable<{ x: number; y: number }>(),
})
export interface AppMap extends MapOf<typeof contract> {}
main.ts
import { mountPanel } from '@transport-io/devtools'
import { Client } from 'transport-io'
import { connectDev } from 'transport-io/dev-transport'
import { type AppMap, contract } from './contract.ts'
export const client = new Client<AppMap>({ contract, connect: () => connectDev() })
// on your own machine only
if (['localhost', '127.0.0.1'].includes(location.hostname)) mountPanel(client)
await client.connect()

mountPanel(client, options?) puts a launcher in the bottom right corner of the page, which opens the panel, and returns the unmount. It reads no environment, so whether a build gets a panel is the line you wrote. Mount it before connect() and the handshake is the first thing it shows. The open panel sits along the bottom of the window and pads the page by its own height, so a control it would have covered is a scroll away.

That condition keeps the panel off the page in production, and its code is still in the bundle. Import it where the condition is, and it becomes a chunk of its own that only your machine ever requests:

lazy.ts
import { Client } from 'transport-io'
import { connectDev } from 'transport-io/dev-transport'
import { type AppMap, contract } from './contract.ts'
export const client = new Client<AppMap>({ contract, connect: () => connectDev() })
if (['localhost', '127.0.0.1'].includes(location.hostname)) {
const { mountPanel } = await import('@transport-io/devtools')
mountPanel(client)
}

In React it is a component, anywhere in the tree:

tools.tsx
import { TransportDevtools } from '@transport-io/devtools/react'
import type { Client } from 'transport-io'
import type { AppMap } from './contract.ts'
export function Tools({ client }: { client: Client<AppMap> }) {
return <TransportDevtools client={client} />
}

It renders null unless the build’s process.env.NODE_ENV is development, and a production bundle contains none of the panel’s code. It takes the client as a prop.

open: true starts it open, preview: true shows the first 256 bytes of each payload, capacity is how many records it keeps, 1,000 unless given, and visibleRows is how many the list shows, 200 unless given.

Closed, nothing that can be measured. Open, between 1 and 34 ms of main-thread time a second, 3% of it at worst, with no dropped frame. Measured on the chat example with two clients in a room and both pointers driven at 60, 120 and 280 events a second, in headless Chromium. So leave it mounted, and close it before you profile your own page.

A closed panel still records, so opening it shows what already happened.

The frame list close up, with the columns time, dir, lane, kind, event, stream, size, seq and preview. Two overflow-dropped rows in the accent colour sit between dim datagram rows, then a chat emit out and the same message in, then a say stream on stream 2: open, request, and one response per word.The frame list close up, with the columns time, dir, lane, kind, event, stream, size, seq and preview. Two overflow-dropped rows in the accent colour sit between dim datagram rows, then a chat emit out and the same message in, then a say stream on stream 2: open, request, and one response per word.

Drops in the accent, datagrams dim, and a stream() from its open to its responses, all on stream 2.

One row for every frame in and out, newest last. A divider marks each new session, since a reconnect is one. A dim row is on the unreliable lane. A row in the accent colour is a drop.

Column
time UTCWhen this client sent it or received it, in UTC, to the millisecond.
dir→ out is sent by this client, ← in is received. For open and close, which side opened the stream.
lanereliable or unreliable, as the contract declares the event.
kindWhat the row is. The next table has each one.
eventThe event’s name. Empty for a frame that carries none, such as a handshake.
stream0 is the emit stream, which carries every emit on the reliable lane. 1 and up is one call() or stream(), numbered as they open. Empty for a datagram, which travels on no stream. On a fallback session everything is 0.
sizeBytes on the wire, header included. 0 for open and close, which are not frames.
seqA datagram’s sequence number. Empty otherwise.
previewEmpty unless you passed preview: true. Then the first 256 bytes of a JSON payload, or the first 32 bytes of a bytes() payload as hex.
kind
handshakeThe first frame each way: protocol version and event table.
emitAn event on the reliable lane.
datagramAn event on the unreliable lane.
open, closeA call() or a stream() starting and ending. Everything between them with the same stream number belongs to it.
requestThe payload of a call() or a stream().
responseThe answer to a call, or one element of a stream.
errorThe responder failed, and this is what it said.
creditA stream’s consumer telling its producer it may send more. See Backpressure.
join, leaveThe server saying it put this client in a room, or took it out.
the four dropsThe table below.
The panel in a narrow window after a burst of pointer events. The bar's overflow counter is above zero and in the accent colour while the other counters read zero, the list alternates dropped and sent cursor datagrams, and the side column says cursor overflow-dropped with the same count.The panel in a narrow window after a burst of pointer events. The bar's overflow counter is above zero and in the accent colour while the other counters read zero, the list alternates dropped and sent cursor datagrams, and the side column says cursor overflow-dropped with the same count.

A burst of pointer events outran the datagram queue, so overflow is not zero, and the list and the side column both say the drops were cursor.

The bar along the top has the counters from client.stats(), for the current session. They start again with each session. Next to them, Drops by event says which event each drop was, since the panel mounted. A drop is its own row too, in the accent colour, straight after the row of the frame it discarded, with the same seq.

In the barstats()Row kindWhat happened
queuequeueDepthDatagrams waiting to be sent right now. Not a drop.
overflowoverflowDroppedoverflow-droppedYou emitted faster than datagrams leave. The queue holds 64, and the oldest was pushed out to make room.
stalestaleDroppedstale-droppedA datagram waited 150 ms in the queue and was discarded unsent, because a late position is worse than none.
stale rxstaleReceivedstale-receivedA datagram arrived that was a duplicate, or older than one already delivered, and was not handed to your handler.
directiondirectionDroppeddirection-droppedThe server sent an event the contract says only a client sends. See the two lanes.

overflow and stale climbing means this client produces datagrams faster than it can send them: send fewer. stale rx climbing is the network reordering or duplicating, and costs you nothing.

Open streams: every call and stream in flight, with the frames and bytes that have crossed it.

The connection: status, transport, and why a session is on the fallback.

Pause stops keeping records, so the rows you are reading are not overwritten, and counts what it skipped. Filter by event or by lane. Copy rows puts the visible rows on the clipboard as text, under two lines that say what they were taken from. That text is what to paste into an issue.

The panel reads one method, and a logger can read the same one. client.observe() calls you with a record for every frame, every call stream opening and closing, and every drop:

log-drops.ts
import type { Client } from 'transport-io'
import type { AppMap } from './contract.ts'
export function logDrops(client: Client<AppMap>): () => void {
return client.observe((record) => {
if (record.kind.endsWith('-dropped') || record.kind === 'stale-received') {
console.warn(`${record.kind}: ${record.event} #${record.sequence}`)
}
})
}

A client nobody observes builds no records. A record holds no payload; { preview: true } adds the start of each one as a string, to the subscriber that asked and nobody else. The fields are in the API reference.

These are this client’s drops. The network’s loss is not visible from here, and neither is what the server dropped on its way to this client, which is peer.stats() on the server.

A gap in the sequence numbers is not loss. The server numbers an event across every room, so a gap can be a broadcast this client was not part of.

The panel records from when it mounts. The counters come from stats() and cover the whole session either way.

Previews are payloads. preview: true puts the start of each message on screen and in anything you copy.

An observer runs inside the session, once per frame. Do one cheap thing in it: append to a bounded list, bump a counter. Never keep JSON.stringify(payload).slice(0, 256) from a handler as a preview of your own: a sliced string keeps the whole payload alive.