Server-Sent Events

SSE is built into bext-core — no feature flag required. bext provides both a low-level Response::sse builder for custom streams and a high-level SseChannelHub for named pub/sub channels.

Response Builder

The simplest way to send SSE is the Response::sse builder. Pass an async stream of events and an optional reconnect interval:

use bext::Response;

async fn stream_updates(req: Request) -> Response {
    let stream = async_stream::stream! {
        for i in 0..10 {
            yield SseEvent::new()
                .id(i.to_string())
                .event("tick")
                .data(format!(r#"{{"count":{i}}}"#));
            tokio::time::sleep(Duration::from_secs(1)).await;
        }
    };

    Response::sse(stream)
        .reconnect_ms(3000)
        .build()
}

The response sets Content-Type: text/event-stream, Cache-Control: no-cache, and Connection: keep-alive automatically.

Named Channels

SseChannelHub lets multiple publishers and subscribers share named channels without wiring streams manually:

use bext::sse::SseChannelHub;

// Publish from anywhere (handler, plugin, background task)
sse.publish("chat:room-42", SseEvent::new()
    .event("message")
    .data(r#"{"user":"alice","text":"hello"}"#));

// Subscribe in a handler
async fn listen(req: Request, sse: &SseChannelHub) -> Response {
    let stream = sse.subscribe("chat:room-42");
    Response::sse(stream).build()
}

Channels are created lazily on first publish or subscribe, and garbage collected when the last subscriber disconnects.

Channel Patterns

Channel names support glob-style wildcards for fan-out:

Pattern Matches
chat:room-42 Exact channel only.
chat:room-* All rooms under chat.
notifications:* All notification channels.

Backpressure

Slow clients are a reality. bext handles backpressure without stalling the server or other subscribers:

  1. Each subscriber has a bounded buffer (default 64 events).
  2. When the buffer fills, new events are dropped for that subscriber.
  3. A sse_events_dropped_total metric is emitted per channel, per client.
  4. The subscriber receives a synthetic dropped event with the count of missed events so the client can request a full refresh if needed.

Configure buffer size per channel:

[sse]
default_buffer_size = 64

[sse.channels.chat]
buffer_size = 256

Last-Event-ID Reconnection

When a client reconnects with a Last-Event-ID header, bext replays missed events from an in-memory ring buffer:

  • Default ring size: 1000 events per channel.
  • If the requested ID is older than the ring, the client receives a replay_incomplete event so it knows to do a full fetch.
[sse]
replay_ring_size = 1000

The client side is standard — the browser's EventSource sends Last-Event-ID automatically on reconnect.

Example: AI Streaming Responses

LLM APIs universally stream completions over SSE. Here is a handler that proxies an upstream LLM stream to the browser:

async fn ai_stream(req: Request) -> Response {
    let prompt = req.json::<PromptRequest>().await?;
    let upstream = llm_client.stream_completion(&prompt).await?;

    let stream = upstream.map(|chunk| {
        SseEvent::new()
            .event("token")
            .data(serde_json::to_string(&chunk).unwrap())
    });

    Response::sse(stream)
        .reconnect_ms(5000)
        .build()
}

On the client:

const source = new EventSource("/api/ai/stream?prompt=...");
source.addEventListener("token", (e) => {
  const chunk = JSON.parse(e.data);
  appendToOutput(chunk.text);
});

Metrics

bext exposes SSE-specific metrics:

Metric Description
sse_active_connections Current open SSE connections.
sse_events_sent_total Events sent across all channels.
sse_events_dropped_total Events dropped due to backpressure.
sse_channels_active Channels with at least one sub.

Compression

SSE responses are automatically compressed with gzip or brotli when the client sends Accept-Encoding. Because SSE is a long-lived connection, bext flushes the compressor after each event to avoid buffering delays.

Warning

bext's /api/* route responses buffer to completion before delivery — SSE streams sent from an /api/ handler will NOT flush live. Place your SSE handler outside the /api/ prefix (or under a top-level page route) to get true streaming. See the SSE buffering gotcha for details.

Tip

If a slow client fills its 64-event buffer, it receives a synthetic dropped event rather than a hard disconnect. Your client should listen for this event and trigger a full refresh — otherwise it will silently miss updates.

Related

  • WebSockets — bidirectional alternative when the client must push data to the server
  • WebTransport — HTTP/3-based alternative with unreliable datagrams and multiple streams
  • Includes: Realtime — higher-level pub/sub primitives built on SSE and WebSocket
  • Includes: Streaming — page-level streaming (different from SSE)
  • Caching guide — how Cache-Control: no-cache interacts with bext's edge cache

Links