Explained The chaos lab
Dstar renders Phoenix pages over Datastar: the browser sends signals, the server answers with HTML patches over SSE, and there's no client-side app.
Below are two copies of the same inbox app, one built with LiveView and one with Dstar. Each is open in six browser tabs, and every tab shows a live unread badge.
Press the buttons: deploy, drive a tab into a tunnel, revoke a session. Watch what each model has to do. Nothing is measured, it's counted: a number only moves when the real callback would run.
The lab is shared. Everyone reading this page right now is looking at the same lab. If someone else presses Deploy, you'll see it here.
LiveView
Ada 9
tab 1 · socket
Ada 9
tab 2 · socket
Ben 9
tab 3 · socket
Cy 9
tab 4 · socket
Dee 9
tab 5 · socket
Eve 9
tab 6 · socket
server memory 6 copies of page state
Dstar
Ada 9
tab 1 · stream
Ada 9
tab 2 · stream
Ben 9
tab 3 · stream
Cy 9
tab 4 · stream
Dee 9
tab 5 · stream
Eve 9
tab 6 · stream
server memory 0 copies of page state
Someone deployed
All 6 sockets dropped. All 6 reconnected at once: 6 on_mount checks, 6 more mount/3 calls, 6 copies rebuilt from the database.
All 6 streams dropped. All 6 re-POSTed connect() through the router plugs. handle_connect/2 re-read the badge, and mount/2 did not run. Pages without a stream wouldn't have noticed.
Someone deployed
All 6 sockets dropped. All 6 reconnected at once: 6 on_mount checks, 6 more mount/3 calls, 6 copies rebuilt from the database.
All 6 streams dropped. All 6 re-POSTed connect() through the router plugs. handle_connect/2 re-read the badge, and mount/2 did not run. Pages without a stream wouldn't have noticed.
Someone deployed
All 6 sockets dropped. All 6 reconnected at once: 6 on_mount checks, 6 more mount/3 calls, 6 copies rebuilt from the database.
All 6 streams dropped. All 6 re-POSTed connect() through the router plugs. handle_connect/2 re-read the badge, and mount/2 did not run. Pages without a stream wouldn't have noticed.
Someone deployed
All 6 sockets dropped. All 6 reconnected at once: 6 on_mount checks, 6 more mount/3 calls, 6 copies rebuilt from the database.
All 6 streams dropped. All 6 re-POSTed connect() through the router plugs. handle_connect/2 re-read the badge, and mount/2 did not run. Pages without a stream wouldn't have noticed.
Someone deployed
All 6 sockets dropped. All 6 reconnected at once: 6 on_mount checks, 6 more mount/3 calls, 6 copies rebuilt from the database.
All 6 streams dropped. All 6 re-POSTed connect() through the router plugs. handle_connect/2 re-read the badge, and mount/2 did not run. Pages without a stream wouldn't have noticed.
Someone deployed
All 6 sockets dropped. All 6 reconnected at once: 6 on_mount checks, 6 more mount/3 calls, 6 copies rebuilt from the database.
All 6 streams dropped. All 6 re-POSTed connect() through the router plugs. handle_connect/2 re-read the badge, and mount/2 did not run. Pages without a stream wouldn't have noticed.
mount/3 or mount/2 executions. LiveView runs it on the HTTP
render and again on every socket (re)connect. Dstar runs it only on the GET.
on_mount. Dstar
checks on every
request, so its number climbs faster. That's the point: there's one path, and it's
always walked.
Two honest results to look for. First, revoking a session needs a push in both models: a connection that's already open was authorized when it opened. Second, real-time costs the same in both: one PubSub message per open tab. What differs is what each tab does with it. A LiveView process updates its copy, and a Dstar stream re-reads the truth.
The counters above say what runs. This benchmark measures what it costs a server: the same inbox built both ways, thousands of simulated tabs speaking the real LiveView and Datastar protocols, and the server's numbers read from inside its own VM.
| 1000 tabs · median of 3 runs | LiveView | Dstar, with stream | Dstar, no stream |
|---|---|---|---|
| Server memory per open tab | 23.6 KB | 24.4 KB | 0.0 KB |
| Server CPU per tab opened | 1.49 ms | 0.85 ms | 0.49 ms |
| Round trip for the clicking tab, p50 | 16.43 ms | 22.92 ms | 0.86 ms |
| Until every open tab is updated, p50 | 25.34 ms | 31.97 ms | – |
| Bytes pushed to each other tab | 477 B | 569 B | – |
| Server CPU per click, fan-out included | 54.93 ms | 68.5 ms | 1.2 ms |
| Until every tab is back | 265.04 ms | 214.47 ms | – |
| Server CPU for the reconnect storm | 925.0 ms | 354.0 ms | 4.0 ms |
AMD EPYC-Genoa Processor, 8 cores · OTP 28 · Phoenix 1.8.11, LiveView 1.1.33, Bandit 1.12.4 · server and load generator on one machine over loopback · run 2026-10-14T11:15:51Z
Opening a page is cheaper with Dstar. mount runs once instead of twice, so the server spends roughly 35–45% less CPU per tab.
A deploy is cheaper with Dstar. Streams reconnect without remounting: about half the server CPU, and every tab is back sooner.
A page without a stream costs nothing to keep open. No process and no memory: only the clicks cost anything.
Fanning one update out to every tab. LiveView sends a diff; each Dstar stream renders HTML. LiveView uses 11–20% less CPU and sends about 16% fewer bytes per tab.
Holding a live tab open costs the same. About 24 KB per tab either way. The open connection is the cost, not LiveView's copy, which streams keep small.
So use a stream only where a tab really needs live updates. The rest of the page can be plain requests. Reproduce it or run it on your own hardware: bench/README.md
The server holds the only copy of the lab. Every button is a POST through the router.
The reducer is a pure function, DstarDemo.ChaosLab.apply_action/2, and
every open tab follows along through one stream. This is all the page code it takes:
def handle_connect(conn, _params) do
:ok = Server.subscribe()
patch(conn, &lab/1, lab: Server.get())
end
# ...
def handle_info(:chaos_lab_changed, conn), do: patch(conn, &lab/1, lab: Server.get())
# ...
def handle_event(conn, action, _signals) when action in @actions do
patch(conn, &lab/1, lab: Server.dispatch(action))
end
Want the story behind the counters? Follow the arrows
Dstar is a Hex package. Add it next to Phoenix, and a page is one module and one router line:
{:dstar, "~> 0.3.0"}
Built by Rico Trevisan. Tell me where it breaks.
We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect