Bandwidth profiler
The profiler breaks a room’s traffic down per field instead of giving you a total. It answers
“61% of that is one str(64) field on one collection” rather than “this room does 40 kB/s”, so
you know what to change.
Turn it on with irtio dev --profile, with simulate --profile, with joinRoom(schema, { profile: true }) in a browser, or with testRoom({ profile: true }) in a
test. It is off everywhere by default, and off costs nothing: nothing is allocated, no frame is
read twice, and the encoders are the same code either way.
Reading a table
This is a real one: dive, a co-op physics platformer, with two players, over a ten-second window.
room DIVE-7Q2M · 10.0 s window
KIND KEY OUT IN SHARE
overhead op 9.1 kB/s 472 B/s 37%
overhead delta-header 4.2 kB/s 472 B/s 17%
field players.x 961 B/s 0 B/s 3.9%
correction tail 961 B/s 0 B/s 3.9%
field players.y 639 B/s 0 B/s 2.6%
field players.vx 540 B/s 0 B/s 2.2%
correction players.x 480 B/s 0 B/s 2.0%
field props.wz 476 B/s 0 B/s 1.9%
field props.vx 468 B/s 0 B/s 1.9%
field shots.vx 419 B/s 0 B/s 1.7%
total 24.5 kB/s 1.2 kB/s
socket 24.5 kB/s 1.2 kB/s OUT is what the server sent, IN is what it received, and SHARE is that row’s slice of OUT.
The total line is the ledger’s own sum. The socket line is what was written to and read
from the connection over the same window. Both are printed so you can compare them: if they
ever disagreed, the ledger would be the one to distrust.
Rates are always derived from two cumulative readings and a window length. Nothing stores a rate, so a missed poll widens the window rather than losing the bytes.
Read that table by what is missing from it. Every field row is small, the biggest under 4%, and
55% of everything the room sends is overhead: the op framing that says which entity changed,
and the per-frame delta header. Dive sends a great many small updates about a great many entities
at 60 Hz, so it is paying for addressing rather than for data. Only a breakdown that counts the
framing as its own row shows that.
The kinds
Every byte of every frame lands in exactly one row. The kind says what the byte was for; the key says which thing.
| Kind | Key | What it counts |
|---|---|---|
field | collection.field | Field values inside a state update. The steady state, and usually the answer |
presence | clients.field | The built-in clients collection, split out of field so a room’s own state is not mixed with irtio’s |
correction | collection.field, and tail | Field values inside a server-wins correction, plus the tick suffix a correction carries |
write | collection.field | Field values a client wrote to the server |
churn | collection | Area-of-interest enter and leave: the whole add or remove op for an entity that crossed a visibility boundary |
rpc | rpc name | CALL and its REPLY, both directions |
message | all, client, role, server | room.send / client.send traffic, by how it was addressed |
voice | signal | Voice signaling. Media never touches this socket, so this is signaling only |
join | collection.field, and header | The join snapshot, kept out of the steady-state rows so one join does not look like a leak |
overhead | envelope, delta-header, op | Framing: the frame type byte; tick, schema hash and counts; each op’s tag, id, owner and dirty mask |
control | ping, pong, hello, welcome, error, leave, schema | Everything else, whole frame |
Reading overhead
overhead is the price of saying which thing changed rather than what it changed to. It is
the row that tells you your updates are too small. op is a tag byte, an entity id as a string,
and a dirty mask, around ten bytes for a short id. If op is a large share of OUT, you are
sending many tiny updates. The fix is usually fewer, larger ones: a lower tick rate for that
collection’s data, or fewer entities that change every tick.
delta-header is a fixed 12 bytes per frame, the tick and the schema hash, plus the per-collection
framing. If it is significant, you are sending too many frames.
In the dive table above the two together are 55% of what the room sends. The lever is the tick rate for the collections that do not need 60 Hz, and after that the number of entities changing every tick. Neither is a codec problem.
Reading churn
churn is what a spatial-grid collection costs you in movement of the viewer, as opposed to
movement of the world. When an entity crosses into a client’s area of interest, the server sends
that entity’s whole record even though nothing about it changed. When it crosses out, a remove.
Those bytes are neither a spawn nor an update, and counting them as either would hide the cost of
your cell size.
The server tells churn from a real spawn exactly, because it does the encoding: a synthetic add is
one the global dirty set never contained. Once encoded, the two are byte-identical, so a client
cannot make that distinction. In the browser overlay the row is labelled enter/leave (incl. spawns) for that reason. High churn against low real spawn rates means your cells are too small
or your radius too tight for how fast things move.
Reading join
A join is a full snapshot, so it is large and it happens once. It is separated out so that a table
taken over a window that happened to contain a join does not read as though your steady state
tripled. If join dominates a long-running room, players are reconnecting.
On the server
irtio dev --profile prints one table per room per second, plus --profile-top <n> for how many
rows. The ledger also rides /__irt/state.json under each room’s profile, if you would rather
read it with a script.
The room’s ledger is the room’s own view. It counts what the room sent and what the room accepted,
plus the join snapshots it produced. The WELCOME frame is assembled outside the room, so the
room sees the snapshot and not the envelope around it. PING and PONG are answered outside the
room too, so they show up in a browser’s ledger and not in a room’s.
In a test
// irtio/room.test.ts
import { testRoom } from '@irtio/testing';
import room from './room.js';
const t = await testRoom(room, { profile: true });
await t.join(2);
await t.run(1000);
const top = t.profile!.rows.filter((r) => r.kind === 'field')[0];
expect(top.key).toBe('shots.owner'); t.profile is undefined in a room that was not asked to profile, never an empty ledger, because
“nobody measured” and “nothing moved” are different answers.
In the browser
// main.ts
import { joinRoom } from '@irtio/client';
import { schema } from './irtio/schema.js';
const room = await joinRoom(schema, { profile: true });
room.profile?.perSecond(); // the last second, as rows
room.profile?.total(); // cumulative since join room.profile exists only when you asked for it. The client’s ledger covers the whole connection,
so it sees the WELCOME envelope, the pings, and everything the server’s ledger does.
For an on-screen version, @irtio/lobby ships <irt-profile>:
<irt-profile></irt-profile> import '@irtio/lobby'; // registers the element
document.querySelector('irt-profile').room = room; It renders the top rows per second, refreshed once a second, in the same style as <irt-status>.
Mount it behind a flag rather than shipping it. Dive mounts it when the page URL carries ?profile.
Under simulate
irtio simulate --profile runs every bot with a ledger and prints one table for the whole run,
divided per bot the way the rest of that report is, labelled as seen by the bots. That is the view
from the outside: what a real client’s connection carries, rather than what the room believes it
sent.
What it costs
A profiled room reads each frame it encodes one extra time, without decoding it. Values are skipped by width, nothing is allocated per value, and a payload shared by several clients is read once and attributed per recipient. A room that is not profiling constructs no ledger and reads nothing twice, and the encoders are byte-for-byte the same code in both cases. The profiler is a second reader of the wire, never an instrumented writer.
Measured on dive at 60 Hz with four players for thirty seconds, three runs each way: worst tick 9.6 ms with profiling off and 10.1 ms with it on, zero dropped ticks either way, and the same 2.05 MB sent. In the browser the overlay costs about a tenth of a frame per second at 60 Hz.
Next steps
- Visibility for the cell size and radius behind the
churnrow - Simulated players for the bot run
--profileattaches to - Limits for the allowances these bytes count against