Draw the REST of the HOWL
I’ve seen Kalshi a bunch in the headlines lately and rather than join the ranks of the degenerate gamblers, I figured I’d join the house and built a live dashboard. (hire me pls? jk. unless…)
It updates in real time with no JSON API, no client router, no client-side state, and no fecking WebSockets. The browser is doing a ton of work here — routing, holding a stream open, morphing new markup into the DOM — I just never had to tell it to. It already knows how; it’s a hypermedia machine. The server renders HTML and pushes it down one SSE connection per page, Datastar morphs in whatever changed. Five hundred markets, 30MB of RAM, mere bits over the wire.
sorry in advance Delaney, I’m not trying to be one of those AI slop trogledytes, but I can tell where this is going.
The Usual Way (and Why I Skipped It)⌗
The default way to build this in 2026 is a JSON API on the back and a single-page app on the front. The server exposes endpoints, the browser fetches JSON, and a client framework holds its own copy of the market state, reconciles every update against it, and re-renders. Bolt on a WebSocket so the updates are live, and now you’re serializing state on the server, deserializing it on the client, and keeping two copies of the truth in sync across a network.
The pitch for all that machinery is decoupling: a JSON API supports arbitrary frontend clients, and the frontend ships independently from the backend. That’s a genuine win when you’ve got separate frontend and backend teams shipping on separate schedules to web, iOS, and Android. I don’t. I’m a one man show, not an enterprise, and I own both ends of every request. Maintaining a serialization boundary and a second copy of my application state — in a second language — to decouple two things that live in the same repo and deploy together is a tax I’ve been paying out of habit. It’s time to wake up.
Hypermedia, Briefly⌗
There are plenty of good arguments for hypermedia and I won’t rehash all of them here. The one-sentence version: instead of shipping JSON to the client and rebuilding the UI in JavaScript, the server sends HTML and the browser — already a hypermedia client by birth — renders it. Application state lives in one place, and the wire format is the thing you were going to display anyway.
Originally (maybe a year ago? I don’t remember) HTMX caught my eye. Then I saw
Delaney’s Utah JS talk and I started looking into Datastar. This was right
around when the whole “greedy developer” stuff was playing out and the whole
thing gave me pause for a while. Anyway, it obviously wasn’t the rug pull
disaster the HN front page made it out to be (tl;dr the PRO version gates
things you shouldn’t do and the proceeds go to a 501(c)(3) that sustains the
project).
Enter Datastar⌗
If you want a proper introduction to Datastar, better than I could write here, go read Jeff Hui’s writeup; it’s an excellent intro reference.
Remember, the goal is to TOUCH GRASS, guys — keep it simple and get it shipped!
To that end, take a look at go-datastar-minimal and python-datastar-minimal, where I keep minimal implementations — cookiecutter projects you can generate and start from, with sensible defaults that follow the Tao of Datastar: one long-lived read per page; the server runs a loop waiting for updates and pushes full HTML pages down to every client over SSE that get fat-morphed by Datastar. Most projects will reach for Redis, NATS, or even Postgres LISTEN/NOTIFY to handle the pub/sub in the backend. Or you can keep it dead simple with SQLite and Honker. If you’re using Go, take a look at Mat Ryer’s vice.
There are three ideas worth internalizing. Each page is a resource. Each page
opens exactly one long-lived HTTP connection (Content-Type text/event-stream;
it’s just HTTP!). This is the read half of CQRS, and the writes are handled
elsewhere (shoo shoo WebSocket complexity demon). The backend owns all the
state. Whenever the server sends down updates, Datastar morphs it into place,
touching only what actually differs.
The Data Flow in an actual example: Kalshi⌗
This blog post is basically a pointer to kalshi-real-time. It’s the same pattern as the dummy templates above, with one wrinkle: the server has a renderer that publishes page updates, and clients only ever subscribe to those rendered pages.
Kalshi WS ──> Bus ──> Renderer ──> Pages ──> HTTP clients
(1 conn) (quotes) (1 goroutine) (cache) (read only)
Data flows one way. A single goroutine owns the Kalshi WebSocket (🙄) and
publishes quotes to the bus. A single renderer is the bus’s only consumer: it
keeps market state and turns it into rendered HTML on a fixed cadence. Pages are
the unit clients subscribe to — not quotes, not the bus, not market state. /
is a Kalshi series, /markets/{ticker} is one strike, and the page cache is
keyed by URL path, so the resource and the cache entry are the same thing. A
connection’s entire job is to write the current version of one page, wait for
the next one, and repeat. It renders nothing and knows nothing about Kalshi.
Render by Change, Not by Audience⌗
Pages re-render when their data moves — not on a timer, and not based on who’s watching. The renderer tracks which markets ticked since the last pass and redraws only those pages. Cost scales with market activity, not with viewer count or watchlist size.
I made the mistake of trying to be smart first: only render a page if someone’s
looking at it, because “why draw something nobody’s watching”. Well, it needed a
viewer refcount, it handed stale HTML to the next visitor of an abandoned page,
and it let any URL allocate a cache entry — a memory leak addressable by anyone
who can type /markets/garbage. Rendering by change drops all of that
bookkeeping and comes out cheaper: 0.7% of a core versus 8.6% for redrawing
everything on a timer.
Code⌗
Every page boots the same one-liner:
<body data-init="@get('{{.Stream}}')"></body>
That opens a single SSE stream that never returns. The handler sets it up with
one option — datastar.NewSSE(w, r, datastar.WithCompression()) — which turns
on per-connection Brotli. Hold that thought; it does an absurd amount of work in
the benchmarks below. The loop itself is tiny:
var sent []byte
for {
html, next := p.Current()
if html != nil && !bytes.Equal(html, sent) {
if err := sse.PatchElements(string(html)); err != nil {
return // client gone
}
sent = html
}
select {
case <-ctx.Done():
return
case <-next:
}
}
Grab the current render, patch it if it differs from what this connection last
saw, then block until the renderer signals a change on next. No diffing, no
reconciliation, no state on the wire. We probably don’t even need the sent check
and we should just let compression do the diffing automatically for us but
whatever.
Was It Worth It?⌗
Leaning on the browser instead of reinventing it buys you a pile of things for free: two tabs on the same page read the same bytes, the back button just works, view-source shows the real thing, and there’s no hydration step because there was never anything to hydrate. But the part that surprised me was how cheap this shape is to run.
Here’s the whole app in production — one replica, tracking 509 markets across 517 pages:
| Resource | Used | Limit | Headroom |
|---|---|---|---|
| Memory | 30 MiB | 256 MiB | ~12% used |
| CPU | 39 mCPU | 500 mCPU | ~8% used |
Thirty megabytes of RAM for a live dashboard over five hundred markets, on one replica burning 8% of a core.
The bandwidth is the part I still find a little magical. I opened two connections to the same page for the same 60-second window — one asking for Brotli, one for identity — so both saw the identical frames:
| Encoding | Per frame | Per second |
|---|---|---|
| Brotli | 480 B | 313 B/s |
| Identity | 31,411 B | 20,438 B/s |
65 to 1. A full 31 KB page redraw goes over the wire in under half a kilobyte. Brotli keeps one compression context per connection, so every frame is compressed against everything already sent on that socket. Consecutive frames differ by only the few digits that moved, so what actually ships is the delta. I never wrote a diff, never reconciled a DOM, never sent a byte of client state. And a market sitting still renders byte-identical HTML, which gets dropped before it reaches the socket — a quiet page sends nothing. Thanks Brotli.
So: was it worth it? For this problem — one authoritative writer, many read-only viewers, data that changes on its own schedule — unequivocally yes. The fatter stack would have earned its keep if the client needed to own optimistic local state, work offline, or drive rich interactions the server can’t see. This app needs none of that. The market is the writer; the browser just watches. Let it.
Full methodology and reproduction steps are in BENCHMARKS.md.