Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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)).await

Reconnection

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.