Zodiac can run in asynchronous mode, which lets handlers hold a connection open without occupying a request worker thread. This enables long-lived connections such as Server-Sent Events (SSE) and streaming responses.
Async support is built on Ring's 3-arity handler contract. A synchronous handler takes one argument and returns a response:
(fn [request] {:status 200 :body "hello"})
An asynchronous handler takes three arguments and calls respond with the
response (or raise with an exception) rather than returning:
(fn [request respond raise]
(respond {:status 200 :body "hello"}))
Set the top-level :async? option to true:
(z/start {:routes routes
:async? true})
This does two things:
You do not need to set :jetty {:async? true} yourself — the :async? option
does it for you. Any options you pass under :jetty are still forwarded to the
adapter, so you can tune async behavior, for example the async timeout:
(z/start {:routes routes
:async? true
;; 0 = no timeout; keep long-lived connections open
:jetty {:async-timeout 0}})
When the server runs in async mode, Jetty invokes every handler with three
arguments. Ordinary Ring and Reitit do not upgrade a synchronous handler's
arity, so a plain (fn [request] ...) handler would normally throw an
ArityException.
Zodiac handles this for you. Handlers are treated as synchronous by default: a
handler is called with a single argument and its return value becomes the
response. A route opts into the asynchronous contract with :zodiac/async? true
in its route data:
(def routes
[""
;; Synchronous handler (the default) — no changes needed in async mode.
["/" {:handler index-handler}]
;; Asynchronous handler — receives (request respond raise).
["/events" {:handler events-handler
:zodiac/async? true}]])
You can also write the marker as :zodiac.core/async?, which is convenient when
zodiac.core is aliased to z:
(require '[zodiac.core :as z])
["/events" {:handler events-handler
::z/async? true}]
Deciding sync vs. async per route (rather than inspecting the handler at
runtime) is deliberate: handlers wrapped with constantly, comp, or partial
cannot be reliably introspected, so an explicit marker is the only correct
signal.
Errors raised via the raise callback flow through Zodiac's exception
middleware exactly as thrown exceptions do for synchronous handlers. A raised
exception is turned into a response (by default a 500), not propagated to the
server. Custom :error-handlers apply to both sync and async handlers.
The *request*, *router*, and *session* dynamic vars are bound around the
handler invocation. In an async handler, work often continues on a different
thread after the handler returns (for example inside a future or a streaming
body), where those thread-local bindings do not apply.
In async handlers, read from the request map directly instead:
(fn [request respond raise]
(let [session (:session request)
router (:reitit.core/router request)]
(respond ...)))
z/url-for relies on *router*; if you need it off the handler thread, capture
the router from the request and bind *router* yourself, or pass the router
explicitly.
In async mode, every middleware in the stack is invoked with the 3-arity form.
Zodiac's built-in middleware already supports this. Any custom or third-party
middleware you add must also implement the 3-arity ([request respond raise])
form — this is a Ring-wide requirement that Zodiac cannot paper over. A
synchronous-only middleware in the chain will throw an ArityException when the
server runs in async mode.
A middleware that supports both looks like:
(defn my-middleware [handler]
(fn
([request]
(handler (assoc request :x 1)))
([request respond raise]
(handler (assoc request :x 1) respond raise))))
A long-lived streaming response uses a body that implements
ring.core.protocols/StreamableResponseBody. The handler responds immediately
and the body is written incrementally, holding the connection open. See the
runnable example in examples/async-sse/.
(require '[ring.core.protocols :as ring.protocols])
(defn sse-body [n]
(reify ring.protocols/StreamableResponseBody
(write-body-to-stream [_ _response out]
(try
(dotimes [_ n]
(.write out (.getBytes (format "data: %s\n\n" (java.time.Instant/now)) "UTF-8"))
(.flush out)
(Thread/sleep 1000))
(finally
(.close out))))))
(defn events-handler [_request respond _raise]
(respond {:status 200
:headers {"content-type" "text/event-stream"
"cache-control" "no-cache"}
:body (sse-body 10)}))
(def routes
[["/events" {:handler events-handler
:zodiac/async? true
;; SSE is a GET the browser can't attach a CSRF token to.
:zodiac/skip-csrf true}]])
It is worth being precise about what async mode does and does not change for a long-lived or large response (a big file download, an SSE stream, etc.).
The response body is written to the client with a blocking write. Ring's
Jetty adapter writes the body by calling
ring.core.protocols/write-body-to-stream synchronously against a blocking
output stream — it does not use the Servlet non-blocking WriteListener API.
This is true in both synchronous and asynchronous mode. So streaming always
occupies a thread for the full duration of the write.
What async mode changes is which thread is occupied:
respond. If you call respond from a future or your own executor, the
blocking write occupies a thread from that pool, not Jetty's request pool.In other words, async mode does not make the write non-blocking — it lets you move the blocking write off Jetty's request-worker pool so slow clients and long streams don't exhaust it.
On JDK 21+ the simplest way to scale blocking streams is to run them on virtual
threads, where a parked (blocked-on-I/O) thread costs almost nothing. Zodiac
passes :jetty options straight through to the Ring Jetty adapter, so you can
hand Jetty a thread pool backed by a virtual-thread executor without any
framework-specific option:
(import '[org.eclipse.jetty.util.thread QueuedThreadPool]
'[java.util.concurrent Executors])
(z/start
{:routes routes
:async? true
:jetty {:thread-pool
(doto (QueuedThreadPool.)
(.setVirtualThreadsExecutor
(Executors/newVirtualThreadPerTaskExecutor)))}})
With this, a long download or stream parks a cheap virtual thread instead of a platform request worker, so the server can hold a very large number of concurrent slow connections. This is a Jetty configuration concern rather than a Zodiac feature — Zodiac stays a preconfigured Ring app and lets the adapter own the threading model.
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 |