Dstar Demo

Explained The chaos lab

Go ahead, break it

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

mount runs
66
auth checks
66
open connections
6
messages between processes
0

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

nothing held, the database is the copy
mount runs
6
auth checks
66
open connections
6
messages between processes
0
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

How to read the counters

mount runs
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.
auth checks
Times a session was checked, by the router plugs or by 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.
copies in memory
Page state held on the server between events. One per live LiveView tab. Zero for Dstar, which re-reads the database instead.
open connections
WebSockets or SSE streams. They're equal here on purpose: both pages have a live badge. A Dstar page with no stream would show zero.

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.

Counted above. Measured here.

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

What the measurements say

  • 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.

Where LiveView wins

  • 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

This lab is a Dstar page

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:

lib/dstar_demo_web/pages/explained_lab_page.ex
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

Try it

Dstar is a Hex package. Add it next to Phoenix, and a page is one module and one router line:

mix.exs
{:dstar, "~> 0.3.0"}

Built by . Tell me where it breaks.