Skip to content
Built with

Server-sent events ​

Build & Deploy

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.

ts
// 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:

http
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
X-Accel-Buffering: no

When 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().

ts
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().

ts
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:

ts
return eventStream(start);

This step turns keepalives off, or changes the interval and comment text.

ts
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().

ts
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.

ts
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.

ts
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.

ts
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.

Next steps ​

implemented.stream AI