Remuda holds server-side view state and reports which paths changed. Darkstar turns that into something a browser can apply: CSS selectors, Datastar patch modes, and the client-side expressions that call back into your handlers.
State lives on the server, the browser runs no application code, and updates are pushed as HTML fragments over a persistent connection. That suits some applications well and others badly.
Good fits:
Poor fits:
Every viewer holds one open SSE connection for as long as their page is open, so a server runs thousands of idle long-lived connections rather than many short requests. That inverts the usual sizing question: what matters is the cost of a parked connection, not requests per second.
Darkstar names no server — the transport is a function you supply — but the choice is consequential.
Measured on one laptop (macOS, JDK 26, 8 cores), idle connections, marginal cost after subtracting a zero-connection baseline:
| transport | per connection | threads | outcome |
|---|---|---|---|
| http-kit | 3.4 KB | 17 | 16,315 held flat |
| Jetty + virtual threads | 22.6 KB | 21 | comparable counts |
| Jetty + platform threads | — | one per connection | fails at ~4,060 |
Platform threads park an OS thread per connection and hit the kernel's thread limit, so they are not viable at this shape. Both other transports handle tens of thousands.
(jetty/run-jetty handler
{:port 3000
:async? true
:async-timeout 0
:thread-pool (doto (org.eclipse.jetty.util.thread.VirtualThreadPool.)
(.setMaxConcurrentTasks 100000))})
:async? true and :async-timeout 0 are required: a parked SSE handler must not
occupy a request worker or be timed out.
Raise maxConcurrentTasks. It defaults to 200 and is a semaphore, so a
blocking SSE handler holds a permit for the life of the connection and connection
201 never gets a handler. The default reads as "virtual threads cap at 200".
(hk/run-server handler {:port 3000 :thread 32})
Event-driven, so on-open returns immediately and there is no per-connection
thread to cap. Roughly 6.6× less memory per connection than Jetty's virtual
threads, and no knob to get wrong. Needs the http-kit Datastar adapter rather than
the Ring one.
Heap was never the binding constraint: 16,315 connections used 65 MB of a 2 GB heap, so 2 GB and 4 GB runs were indistinguishable. The runs were bounded by the load generator's ephemeral ports rather than by any server, so the ceiling is unknown.
Dividing 2 GB by 3.4 KB suggests roughly 600k connections of view state, but that excludes socket buffers, TLS state and per-socket kernel memory, and assumes GC behaves at 600k live objects as it does at 16k. That is arithmetic, not a measurement.
These are idle connections — no diffing, no pushing. Broadcast fan-out is the
workload that matters in production and is measured separately in
dev/fanout.clj. dev/soak.clj and bin/soak.sh hold the harness and the full
caveats.
darkstar.patch — changed paths to {:mode :selector} patch descriptions,
mapped onto Datastar's patch modes. Emits plain maps rather than calling the
SDK, so it is testable with no server running.darkstar.action — the client expressions that invoke your handlers.darkstar.engine/dispatch-opts — the options Remuda's engine needs.Bind an event to a handler:
(require '[darkstar.action :as action])
[:button {:data-on:click (action/post "/live/act" :remove {:id 42})} "delete"]
;; renders:
;; @post('/live/act', {payload: {"event":"remove","id":42,"liveId":$liveId}})
Args travel as a JSON payload, so types survive the round trip: 42 arrives as a
number and "42" as a string. action/get, put, patch and delete are also
available.
Then run an interaction:
(require '[darkstar.engine :as d*engine]
'[remuda.engine :as engine])
(engine/dispatch! eng id :remove {:id 42} d*engine/dispatch-opts)
Pass dispatch-opts rather than assembling the options map yourself. It carries
:retarget-fn, and without it a patch that widens to the component root keeps its
narrower target — root HTML sent against a child selector, which corrupts the DOM
with no error.
dev/ holds a runnable slice, a fan-out benchmark and a soak harness.
examples/adapters/ has a bare http-kit server using no Ring, Zodiac or reitit.
clojure -M:slice -m slice # http://localhost:3000
Working and tested, not released.
MIT. See LICENSE.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |