Server-sent events
Stream events from a route to the browser with void/sse. The SDK formats messages, sends keepalives, and closes the stream when the request ends.
On this pageOpen a server-sent event stream · Send events · Handle keepalives and disconnects
SSE is a good fit for AI tokens, progress updates, or logs produced by one request. If other requests need to publish to connected clients, see Live Event Streams. Store events yourself if clients need to replay them.
Server streams
This step streams events. Return eventStream() from a route handler.
// routes/api/events.ts
import { defineHandler } from 'void';
import { eventStream } from 'void/sse';
export const GET = defineHandler((c) => {
return eventStream(
async (stream) => {
await stream.comment('connected');
await stream.send({
event: 'ready',
data: { now: Date.now() },
});
const timer = setInterval(() => {
void stream.send({ event: 'tick', data: { now: Date.now() } }).catch(() => stream.close());
}, 1000);
await stream.closed;
clearInterval(timer);
},
{
signal: c.req.raw.signal,
},
);
});eventStream() returns a Response immediately with these default headers:
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
X-Accel-Buffering: noWhen the start callback returns, the stream closes. Long-lived streams should wait on stream.closed, a producer promise, or an application cancellation signal before returning.
Sending events
This step sends events with send() and comments with comment().
await stream.send({
id: 'evt_1',
event: 'post.update',
retry: 1000,
data: { id: 'post_123', likes: 10 },
});
await stream.comment('still connected');data accepts strings or JSON-serializable values. SSE is text-only.
Writing to a closed stream throws SseStreamClosedError.
This step formats an already serialized payload with formatSseText().
import { formatSseText } from 'void/sse';
const frame = formatSseText({
event: 'post.update',
data: JSON.stringify({ id: 'post_123', likes: 10 }),
});Keepalives
Keepalives are enabled by default every 15 seconds:
return eventStream(start);This step turns keepalives off, or changes the interval and comment text.
return eventStream(start, { keepAlive: false });
return eventStream(start, {
keepAlive: { intervalMs: 5000, comment: 'ping' },
});Last event ID
Browsers send Last-Event-ID when they reconnect after an event with an id. This step resumes from your own storage with getLastEventId().
import { getLastEventId } from 'void/sse';
export const GET = defineHandler((c) => {
const lastId = getLastEventId(c.req.raw);
return eventStream(async (stream) => {
await stream.send({ id: nextId(lastId), data: await loadNextItem(lastId) });
});
});Client
This step connects from the browser with connectEventStream() from void/sse/client.
import { connectEventStream } from 'void/sse/client';
const stream = connectEventStream<{
ready: { now: number };
tick: { now: number };
}>('/api/events', {
withCredentials: true,
});
const offReady = stream.on('ready', (event) => {
console.log(event.data.now);
});
stream.on('tick', (event) => {
console.log(event.data.now);
});
offReady();
stream.close();The client wraps native EventSource. It supports cookie credentials and typed handlers. It does not support request bodies or custom headers. If you need custom headers, use cookie auth, signed URLs or a fetch-based streaming route.
Payloads are parsed as JSON by default. For other payloads, set parse: 'text' or pass a custom parser.
const logs = connectEventStream('/api/logs', { parse: 'text' });
logs.on('line', (event) => {
console.log(event.data);
});Auth
SSE routes are ordinary route handlers. Use the same auth checks as your JSON routes.
import { requireAuth } from 'void/auth';
export const GET = defineHandler(async (c) => {
const user = await requireAuth(c);
return eventStream(async (stream) => {
await stream.send({ event: 'ready', data: { userId: user.id } });
await stream.closed;
});
});For shared topics and subscriptions, use Live Event Streams. For two-way communication, use WebSockets.