The picture on the wire — measured 2026-09-21
A design note from the engineering record. Why
apps/blobskeeps sending a drawing rather than a world, written after an investigation into Datastar's Rocket component bundle asked whether the demo could become a surface the server drives but does not own frame by frame. The answer is no, not yet, and the reason is a measurement.
The question. Datastar v1.0.4 moved Rocket, its web-component API, out
of Pro and into a free bundle. Rocket's own flagship example is a flow
graph in which "the server owns the graph state while Rocket provides a
canvas" — the same claim apps/blobs makes, and a shape blobs might
adopt: send the world, let the client draw it, stop paying for a picture
sixty times a minute. That is worth asking about precisely because blobs
already pays for a picture.
What blobs sends. One datastar-patch-signals frame per producer
step, carrying every slot as a CSS polygon(...) string of NVERT = 48
vertex pairs at one decimal place, plus four readouts. Sixteen slots
filled would be about 11 KB. That number is what the case for changing it
rested on, and it is the wrong number.
The measurement
Forty consecutive frames from a server with three blobs dropped on the
stage, captured with curl -sN -H 'Accept: text/event-stream' against a
M0_WORKERS=1 build on its own port:
| signal JSON per frame | 1,950 B min, 2,544 B median, 4,296 B max |
| whole SSE frame | 2,595 B median |
| filled slots | 5 of 16 median |
| slots that CHANGE between consecutive frames | 4 median, 7 max |
| share of signal bytes that is vertex text | 92.7 % |
At 10 Hz that is about 26 KB/s per viewer, not the 110 KB/s the
16-blob worst case implies, and the median frame leaves roughly 62 KB of
BUS_MAX_FRAME headroom rather than 54.
Two things follow, and the second is the one that settles it.
Delta frames are worth almost nothing here. Four of the five filled
slots change every frame, because metaballs are in constant motion — a
blob at rest still breathes. Sending only what changed saves about a
fifth, against the cost of overturning a decision with a stated reason
(wire.mojo: "a missed full frame is healed by the next one, a missed
delta would leave a stale blob on that screen forever").
Vertices are 92.7 % of the payload, so the only real reduction is to stop sending vertices. Sending blob state instead — centre, radius, phase, seed, hue, about 30 bytes each — is roughly 500 B per frame, a 20x cut. There is no middle: trimming precision or vertex count buys a factor of two and costs contour quality that took four rules to get right.
Why state on the wire moves the kernel with it
Tracer.trace is five stages, not one:
sample the scalar field over a 192x192 grid
march marching squares at the isovalue
chain segments into closed loops
resample each loop to exactly NVERT vertices
match each polygon to the slot it held last step
match is the one that matters. It gives a continuing blob the slot it
already had, turns its vertex order to the rotation nearest its previous
polygon (_best_shift, _rotate), gives a merged shape the biggest
contributor's slot, prefers an empty slot for a new polygon over one that
just emptied, and animates births and farewells (BORN_COPY,
BORN_SEED, LEAVE_SHRINK, LEAVE_HIDE). All of that exists so that CSS
can interpolate between two frames at all: transition: clip-path only
interpolates polygons with equal vertex counts, and it interpolates
vertex i to vertex i, so a rotation nobody corrected would turn a
drifting blob inside out.
A client that receives state and draws it must reproduce every one of
those stages, including the correspondence — otherwise the animation is
not merely different, it is wrong in ways that appear only when blobs
merge, split or die. That is a port of kernel.mojo, not a renderer.
So the wire reduction and the second kernel are one change, not two. An earlier sketch of this said the wire change was "server-side only, no client dependency"; that was wrong, and the error is recorded here because it is the kind that makes a cheap-looking piece expensive.
The decision
Not now. 26 KB/s per viewer is not a cost blobs is paying badly, and
the price of removing it is a second implementation of a contouring
kernel whose rules each cost a round — the tree's own standing warning
(split_data_lines, duplicated for a release, where one unauthenticated
POST killed the server on the loop thread: "duplicating a function
duplicates its traps"). It would also rewrite SPEC N16's gate, whose
subject is that underscored signals draw the slots.
What blobs already does, and should keep getting credit for. The
server does not own every displayed frame today. It publishes at 10 Hz
(2 Hz idle) and transition: clip-path var(--period) linear fills the
gap at the display's own rate, with --period carried as _period_ms so
the easing tracks a producer that slowed down. That is the
server-drives/client-fills split, with no JavaScript and no second
kernel — which is why the demo can claim no interpreter in its image at
all.
One fact makes the future cheap when it arrives. Blobs sends only
patch_signals and never patch_elements, so the surface is never
morphed by the server. Whatever eventually owns those pixels — a canvas,
a Rocket mode: 'light' custom element, a WebGL context — needs nothing
protecting it from a morph that does not happen.
What would retire this is an application, not an optimisation: a surface CSS genuinely cannot draw — a graph with edges, a virtual scroll, a zoomable canvas — at which point the client needs a real renderer anyway and the state-shaped wire is free. Blobs is not that application. Reaching for it to prove the framework would be building the demo for the framework rather than the other way round.
Reproducing the measurement
uv run mojo build -I packages/m0-core/ -I packages/m0-http/ \
-I packages/m0-datastar/ -I apps/ apps/blobs/server.mojo -o /tmp/blobs
M0_PORT=8931 M0_WORKERS=1 /tmp/blobs &
for i in 1 2 3; do
curl -s -X POST http://127.0.0.1:8931/drop -H 'Content-Type: application/json' \
-d "{\"x\":$((20+i*20)),\"y\":$((30+i*15))}" >/dev/null
done
curl -sN --max-time 4 -H 'Accept: text/event-stream' http://127.0.0.1:8931/events
Its own port, because SO_REUSEPORT lets a stray blobs answer for a
shared one and fake either result.