<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Kalshi on Jon Brown&#39;s Webpage</title>
    <link>/tags/kalshi/</link>
    <description>Recent content in Kalshi on Jon Brown&#39;s Webpage</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en</language>
    <lastBuildDate>Wed, 12 Aug 2026 10:55:12 -0700</lastBuildDate><atom:link href="/tags/kalshi/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Draw the REST of the HOWL</title>
      <link>/posts/datastar-kalshi/</link>
      <pubDate>Wed, 12 Aug 2026 10:55:12 -0700</pubDate>
      
      <guid>/posts/datastar-kalshi/</guid>
      <description>&lt;p&gt;I&amp;rsquo;ve seen &lt;a href=&#34;https://kalshi.com&#34;&gt;Kalshi&lt;/a&gt; a bunch in the headlines lately and
rather than join the ranks of the degenerate gamblers, I figured I&amp;rsquo;d join the
house and built a live dashboard. (hire me pls? jk. unless&amp;hellip;)&lt;/p&gt;</description>
      <content>&lt;p&gt;I&amp;rsquo;ve seen &lt;a href=&#34;https://kalshi.com&#34;&gt;Kalshi&lt;/a&gt; a bunch in the headlines lately and
rather than join the ranks of the degenerate gamblers, I figured I&amp;rsquo;d join the
house and built a live dashboard. (hire me pls? jk. unless&amp;hellip;)&lt;/p&gt;
&lt;p&gt;It updates in real time with no JSON API, no client router, no client-side
state, and no &lt;em&gt;fecking WebSockets&lt;/em&gt;. The browser is doing a &lt;em&gt;ton&lt;/em&gt; 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&amp;rsquo;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.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;sorry in advance Delaney, I&amp;rsquo;m not &lt;em&gt;trying&lt;/em&gt; to be one of those AI slop
trogledytes, but I can tell where this is going.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&#34;the-usual-way-and-why-i-skipped-it&#34;&gt;The Usual Way (and Why I Skipped It)&lt;/h2&gt;
&lt;p&gt;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&amp;rsquo;re serializing state on the server, deserializing it on the
client, and keeping two copies of the truth in sync across a network.&lt;/p&gt;
&lt;p&gt;The pitch for all that machinery is decoupling: a JSON API supports arbitrary
frontend clients, and the frontend ships independently from the backend. That&amp;rsquo;s
a genuine win when you&amp;rsquo;ve got separate frontend and backend teams shipping on
separate schedules to web, iOS, and Android. I don&amp;rsquo;t. I&amp;rsquo;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&amp;rsquo;ve
been paying out of habit. It&amp;rsquo;s time to wake up.&lt;/p&gt;
&lt;h2 id=&#34;hypermedia-briefly&#34;&gt;Hypermedia, Briefly&lt;/h2&gt;
&lt;p&gt;There are plenty of good arguments for hypermedia and I won&amp;rsquo;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.&lt;/p&gt;
&lt;p&gt;Originally (maybe a year ago? I don&amp;rsquo;t remember) HTMX caught my eye. Then I saw
Delaney&amp;rsquo;s Utah JS talk and I started looking into Datastar. This was right
around when the whole &amp;ldquo;greedy developer&amp;rdquo; stuff was playing out and the whole
thing gave me pause for a while. Anyway, it obviously wasn&amp;rsquo;t the rug pull
disaster the HN front page made it out to be (tl;dr the &lt;code&gt;PRO&lt;/code&gt; version gates
things you shouldn&amp;rsquo;t do and the proceeds go to a 501(c)(3) that sustains the
project).&lt;/p&gt;
&lt;h2 id=&#34;enter-datastar&#34;&gt;Enter Datastar&lt;/h2&gt;
&lt;p&gt;If you want a proper introduction to Datastar, better than I could write here,
go read &lt;a href=&#34;https://jeffhui.net/writings/2025/datastar&#34;&gt;Jeff Hui&amp;rsquo;s writeup&lt;/a&gt;; it&amp;rsquo;s
an excellent intro reference.&lt;/p&gt;
&lt;p&gt;Remember, the goal is to TOUCH GRASS, guys — keep it simple and get it shipped!&lt;/p&gt;
&lt;p&gt;To that end, take a look at
&lt;a href=&#34;https://github.com/brojonat/llmsrules/tree/main/project-templates/go-datastar-minimal&#34;&gt;go-datastar-minimal&lt;/a&gt;
and
&lt;a href=&#34;https://github.com/brojonat/llmsrules/tree/main/project-templates/python-datastar-minimal&#34;&gt;python-datastar-minimal&lt;/a&gt;,
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 &lt;a href=&#34;https://honker.dev/&#34;&gt;Honker&lt;/a&gt;. If you&amp;rsquo;re using Go, take a look at Mat Ryer&amp;rsquo;s
&lt;a href=&#34;https://github.com/matryer/vice&#34;&gt;vice&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;There are three ideas worth internalizing. Each page is a resource. Each page
opens exactly one long-lived HTTP connection (Content-Type &lt;code&gt;text/event-stream&lt;/code&gt;;
it&amp;rsquo;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.&lt;/p&gt;
&lt;h2 id=&#34;the-data-flow-in-an-actual-example-kalshi&#34;&gt;The Data Flow in an actual example: Kalshi&lt;/h2&gt;
&lt;p&gt;This blog post is basically a pointer to
&lt;a href=&#34;https://github.com/brojonat/kalshi-real-time&#34;&gt;kalshi-real-time&lt;/a&gt;. It&amp;rsquo;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.&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;Kalshi WS ──&amp;gt; Bus ──&amp;gt; Renderer ──&amp;gt; Pages ──&amp;gt; HTTP clients
 (1 conn)   (quotes)  (1 goroutine)  (cache)   (read only)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Data flows one way. A single goroutine owns the Kalshi WebSocket (🙄) and
publishes quotes to the bus. A single renderer is the bus&amp;rsquo;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. &lt;code&gt;/&lt;/code&gt;
is a Kalshi series, &lt;code&gt;/markets/{ticker}&lt;/code&gt; 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&amp;rsquo;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.&lt;/p&gt;
&lt;h2 id=&#34;render-by-change-not-by-audience&#34;&gt;Render by Change, Not by Audience&lt;/h2&gt;
&lt;p&gt;Pages re-render when their data moves — not on a timer, and not based on who&amp;rsquo;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.&lt;/p&gt;
&lt;p&gt;I made the mistake of trying to be smart first: only render a page if someone&amp;rsquo;s
looking at it, because &amp;ldquo;why draw something nobody&amp;rsquo;s watching&amp;rdquo;. 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 &lt;code&gt;/markets/garbage&lt;/code&gt;. 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.&lt;/p&gt;
&lt;h2 id=&#34;code&#34;&gt;Code&lt;/h2&gt;
&lt;p&gt;Every page boots the same one-liner:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-html&#34; data-lang=&#34;html&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;body&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;data-init&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;@get(&amp;#39;{{.Stream}}&amp;#39;)&amp;#34;&lt;/span&gt;&amp;gt;&amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;body&lt;/span&gt;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That opens a single SSE stream that never returns. The handler sets it up with
one option — &lt;code&gt;datastar.NewSSE(w, r, datastar.WithCompression())&lt;/code&gt; — which turns
on per-connection Brotli. Hold that thought; it does an absurd amount of work in
the &lt;a href=&#34;/posts/datastar-kalshi/#was-it-worth-it&#34;&gt;benchmarks below&lt;/a&gt;. The loop itself is tiny:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-go&#34; data-lang=&#34;go&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;var&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;sent&lt;/span&gt; []&lt;span style=&#34;color:#66d9ef&#34;&gt;byte&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;for&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;html&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;next&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;:=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;p&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;Current&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;html&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;!=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;nil&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;&amp;amp;&amp;amp;&lt;/span&gt; !&lt;span style=&#34;color:#a6e22e&#34;&gt;bytes&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;Equal&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;html&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;sent&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;err&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;:=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;sse&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;PatchElements&lt;/span&gt;(string(&lt;span style=&#34;color:#a6e22e&#34;&gt;html&lt;/span&gt;)); &lt;span style=&#34;color:#a6e22e&#34;&gt;err&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;!=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;nil&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            &lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt; &lt;span style=&#34;color:#75715e&#34;&gt;// client gone&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#a6e22e&#34;&gt;sent&lt;/span&gt; = &lt;span style=&#34;color:#a6e22e&#34;&gt;html&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;select&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;case&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;-&lt;/span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;ctx&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;Done&lt;/span&gt;():
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;case&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;-&lt;/span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;next&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Grab the current render, patch it if it differs from what this connection last
saw, then block until the renderer signals a change on &lt;code&gt;next&lt;/code&gt;. No diffing, no
reconciliation, no state on the wire. We probably don&amp;rsquo;t even need the sent check
and we should just let compression do the diffing automatically for us but
whatever.&lt;/p&gt;
&lt;h2 id=&#34;was-it-worth-it&#34;&gt;Was It Worth It?&lt;/h2&gt;
&lt;p&gt;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&amp;rsquo;s no hydration step because there
was never anything to hydrate. But the part that surprised me was how &lt;em&gt;cheap&lt;/em&gt;
this shape is to run.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the whole app in production — one replica, tracking &lt;strong&gt;509 markets across
517 pages&lt;/strong&gt;:&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;Resource&lt;/th&gt;
          &lt;th style=&#34;text-align: right&#34;&gt;Used&lt;/th&gt;
          &lt;th style=&#34;text-align: right&#34;&gt;Limit&lt;/th&gt;
          &lt;th style=&#34;text-align: right&#34;&gt;Headroom&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;Memory&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;30 MiB&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;256 MiB&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;~12% used&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;CPU&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;39 mCPU&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;500 mCPU&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;~8% used&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Thirty megabytes of RAM for a live dashboard over five hundred markets, on one
replica burning 8% of a core.&lt;/p&gt;
&lt;p&gt;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:&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;Encoding&lt;/th&gt;
          &lt;th style=&#34;text-align: right&#34;&gt;Per frame&lt;/th&gt;
          &lt;th style=&#34;text-align: right&#34;&gt;Per second&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;Brotli&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;&lt;strong&gt;480 B&lt;/strong&gt;&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;&lt;strong&gt;313 B/s&lt;/strong&gt;&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Identity&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;31,411 B&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;20,438 B/s&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;65 to 1.&lt;/strong&gt; 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 &lt;em&gt;nothing&lt;/em&gt;. Thanks Brotli.&lt;/p&gt;
&lt;p&gt;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&amp;rsquo;t see. This app
needs none of that. The market is the writer; the browser just watches. Let it.&lt;/p&gt;
&lt;p&gt;Full methodology and reproduction steps are in
&lt;a href=&#34;https://github.com/brojonat/kalshi-real-time/blob/main/BENCHMARKS.md&#34;&gt;BENCHMARKS.md&lt;/a&gt;.&lt;/p&gt;
</content>
    </item>
    
  </channel>
</rss>
