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:
- Each subscriber has a bounded buffer (default 64 events).
- When the buffer fills, new events are dropped for that subscriber.
- A
sse_events_dropped_totalmetric is emitted per channel, per client. - The subscriber receives a synthetic
droppedevent 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_incompleteevent 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.
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.
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-cacheinteracts with bext's edge cache
Links
- WHATWG HTML Living Standard — Server-Sent Events — canonical SSE specification including
EventSourceand the event-stream format - MDN: EventSource — browser API reference