Server-Sent Events (SSE)
Push typed events from the server to the browser over a long-lived
text/event-stream response, consumed by the native EventSource. Built on
streaming responses; always available (no Cargo feature).
Emitting events
use ultimo::prelude::*;
use ultimo::SseEvent;
async fn events(ctx: Context) -> Result<Response> {
let evs = vec![
SseEvent::new(&json!({ "user": "ada" }))?.event("join"),
SseEvent::new(&json!({ "msg": "hello" }))?.event("message"),
];
ctx.sse(futures_util::stream::iter(evs)).await
}
// SSE is a GET; `app.sse` reads as intent (sugar over `app.get`).
app.sse("/events", events);Each SseEvent::new(&value) serializes your Rust type to JSON in the data:
field. .event(name) sets the event type the client listens for; .id(..) and
.retry(..) set the SSE id:/retry: fields.
Push / broadcast
Use sse_channel() when events originate outside the handler (a broadcast hub,
a background task):
use ultimo::{sse_channel, SseEvent};
async fn feed(ctx: Context) -> Result<Response> {
let (tx, rx) = sse_channel();
tokio::spawn(async move {
for n in 0..5 {
if tx.send(SseEvent::new(&json!({ "n": n })).unwrap()).is_err() {
break; // client disconnected
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
});
ctx.sse(rx).await
}SseSender is Clone + Send, so share it across tasks. When every sender is
dropped the stream ends and the response closes.
Keep-alive
Idle connections and proxies can drop a quiet stream. sse_keep_alive injects a
: ping comment when idle:
ctx.sse_keep_alive(rx, std::time::Duration::from_secs(15)).awaitReconnection
The browser's EventSource reconnects automatically and sends the last seen
id: back as the Last-Event-ID header. Read it to resume:
let resume_from = ctx.last_event_id();The framework does not buffer past events — replay from resume_from is your
application's responsibility.
Consuming from the browser
const es = new EventSource("/events");
es.addEventListener("message", (e) => {
const data = JSON.parse(e.data);
console.log(data);
});
es.onerror = () => {
// EventSource retries automatically; close to stop.
};Vite dev proxy buffers streaming responses
If your frontend dev server proxies API requests to the backend (e.g. Vite's
server.proxy, used by the fullstack template for /rpc), don't route SSE
through it. Vite's proxy is built on http-proxy, which buffers long-lived
responses instead of piping them through live — a curl against an Ultimo SSE
endpoint returns events immediately, but the identical request through the
Vite proxy emits nothing until the connection is torn down, making
EventSource look hung for the life of the connection in dev.
Point EventSource (and WebSocket connections — same underlying issue) at the
backend origin directly in development instead:
const apiOrigin = import.meta.env.DEV ? "http://localhost:3001" : "";
const es = new EventSource(`${apiOrigin}/events`);Regular REST/RPC calls are unaffected — they're not long-lived, so the proxy buffers and forwards them the same way it would any other request.
Full example
See examples/sse:
cargo run -p sse-example, then open http://127.0.0.1:3000.