Skip to content

Server-Sent Events

SSE is the simplest real-time primitive. One-way, server-to-client, built on HTTP. No upgrade handshake, no special protocol, no Durable Objects. A Responsewith a ReadableStream body and Content-Type: text/event-stream.

When to Use SSE

Use SSE when data flows in one direction: server to client. Notifications, live feeds, progress updates, AI token streaming, dashboard tickers. If you also need the client to send data back over the same connection, use WebSockets.

The Helper

Juice ships eventStream() -- a small function that handles SSE wire format, client disconnect cleanup, and keep-alive. It returns a Response, so it composes naturally with Route-as-API.

import { eventStream } from '@cmj/juice/runtime';

Two forms, same function:

  • Callback -- for event-driven sources (pub/sub, database listeners). You call send() when events arrive.
  • AsyncIterable -- for sequential streams (AI tokens, polling loops). You yield events.

Basic Example: Callback Form

A Route-as-API GET handler that pushes notifications to the client. The callback receives a send function and returns a cleanup function that runs when the client disconnects.

// app/routes/api/notifications.ts
import { eventStream } from '@cmj/juice/runtime';
import type { ActionContext } from '@cmj/juice/runtime';
import { notifications } from '../../lib/notifications.js';

export function GET(req: Request, ctx: ActionContext) {
  return eventStream(req.signal, (send) => {
    // Subscribe to a notification source
    const unsubscribe = notifications.subscribe((notification) => {
      send({
        event: 'notification',
        data: notification,  // objects are auto-JSON'd
        id: notification.id, // enables client resumption
      });
    });

    // Cleanup runs on client disconnect
    return () => unsubscribe();
  });
}

The first argument is req.signal -- the AbortSignal from the incoming request. When the client closes the connection (navigates away, calls EventSource.close(), or loses network), the signal aborts, the stream closes, and your cleanup function runs. No leaked subscriptions.

AsyncIterable Form

When events arrive in sequence rather than from a subscription, an async generator is more natural. This is common for AI token streaming:

// app/routes/api/ai-stream.ts
import { eventStream } from '@cmj/juice/runtime';

export function GET(req: Request) {
  return eventStream(req.signal, async function* () {
    const stream = ai.chat({
      model: 'claude-sonnet-4-5-20250514',
      prompt: new URL(req.url).searchParams.get('q') ?? '',
    });

    for await (const chunk of stream) {
      yield { data: chunk.text };
    }

    yield { event: 'done', data: '' };
  });
}

The generator runs until it returns or the signal aborts -- whichever comes first. If the client disconnects mid-stream, the for await loop breaks and the generator is cleaned up automatically.

Event Fields

Each SSEEvent maps directly to the SSE wire format fields:

FieldWire FormatPurpose
datadata: ...The payload. Strings are sent as-is. Objects are JSON.stringify()'d. Multi-line strings are split into multiple data: lines per the SSE spec.
eventevent: ...Named event type. Without it, the client receives the event on onmessage. With it, the client listens via addEventListener('notification', ...).
idid: ...Sets the client's lastEventId. On reconnection, EventSourcesends this back as the Last-Event-ID header. Use it to resume from where the client left off.
retryretry: ...Overrides the client's reconnection interval in milliseconds. Useful for backoff: send retry: 5000 when the server is under load.

Client Disconnect and Cleanup

SSE connections are long-lived. Resources attached to the connection (database cursors, pub/sub subscriptions, timers) must be freed when the client disconnects.

eventStream() wires req.signal to the stream lifecycle. When the signal aborts:

  1. The keep-alive timer is cleared.
  2. Your cleanup function runs (callback form) or the async iterator returns (iterable form).
  3. The ReadableStream closes.

You do not need to check signal.aborted manually in most cases. The framework handles it. But if you have a long-running computation between sends, check the signal to bail early:

return eventStream(req.signal, async function* () {
  for (const batch of batches) {
    if (req.signal.aborted) return; // bail early
    const result = await expensiveComputation(batch);
    yield { data: result };
  }
});

Keep-Alive

By default, eventStream() sends a : keep-alive comment every 25 seconds. This is invisible to EventSource (comments are ignored by the SSE parser) but keeps the connection alive on platforms that kill idle connections.

Cloudflare Workers terminate connections with no data after 30 seconds. The 25-second default stays safely under that threshold. On Bun, Deno, and Node.js there is no idle timeout by default, but keep-alive is still useful for detecting dead connections through intermediary proxies and load balancers.

// Disable keep-alive (if you send events frequently enough)
eventStream(req.signal, source, { keepAlive: false });

// Custom interval (10 seconds)
eventStream(req.signal, source, { keepAlive: 10_000 });

Per-Runtime Notes

  • Cloudflare Workers: SSE works without Durable Objects (unlike WebSockets). Keep-alive is critical -- 30-second idle kill is enforced. CPU time limits still apply: you get billed per millisecond of active computation, but idle time between events is free.
  • Bun: Full SSE support. No special handling needed.
  • Deno: Full SSE support. No special handling needed.
  • Node.js: Works through the existing Juice adapter that streams Response.body via getReader() and res.write().

Client-Side Patterns

EventSource (simplest)

EventSource is a browser built-in. It handles reconnection automatically, sends Last-Event-ID on reconnect, and parses the SSE wire format for you. The tradeoff: GET-only, no custom headers, no POST body.

// Browser client
const source = new EventSource('/api/notifications');

source.addEventListener('notification', (e) => {
  const notification = JSON.parse(e.data);
  console.log(notification);
});

source.onerror = () => {
  // EventSource auto-reconnects. This fires on each retry.
  console.log('Connection lost, reconnecting...');
};

// Cleanup
source.close();

fetch + getReader (flexible)

When you need POST requests, custom headers, or finer control over reconnection, use fetch() with a streaming reader. You parse the SSE format yourself.

async function streamEvents(url: string, onEvent: (data: string) => void) {
  const res = await fetch(url, {
    headers: { Accept: 'text/event-stream' },
  });

  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const events = buffer.split('\n\n');
    buffer = events.pop()!; // incomplete event stays in buffer

    for (const event of events) {
      const dataLine = event.split('\n')
        .filter(line => line.startsWith('data: '))
        .map(line => line.slice(6))
        .join('\n');
      if (dataLine) onEvent(dataLine);
    }
  }
}

React Hook Pattern

Juice intentionally does not export a client-side hook -- the right state management approach varies by application. Here is a pattern you can adapt:

'use client'
import { useState, useEffect } from 'react';

function useEventSource<T>(url: string, eventName = 'message') {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Event | null>(null);

  useEffect(() => {
    const source = new EventSource(url);

    const handler = (e: MessageEvent) => {
      try {
        setData(JSON.parse(e.data));
      } catch {
        setData(e.data as T);
      }
    };

    source.addEventListener(eventName, handler);
    source.onerror = setError;

    return () => source.close();
  }, [url, eventName]);

  return { data, error };
}

// Usage in a component
export default function Dashboard() {
  const { data: stats } = useEventSource<Stats>('/api/live-stats', 'stats');

  if (!stats) return <div>Connecting...</div>;
  return <div>Active users: {stats.activeUsers}</div>;
}

SSE vs WebSockets vs Polling

SSEWebSocketsPolling
DirectionServer to clientBidirectionalClient to server (pull)
ProtocolHTTPWS (upgrade from HTTP)HTTP
ReconnectionBuilt-in (EventSource)ManualN/A (each request is independent)
ResumptionLast-Event-ID headerManualCursor / offset parameter
Cloudflare WorkersWorks (no Durable Objects)Requires Durable ObjectsWorks
ComplexityLowHighLowest
Best forNotifications, live feeds, AI streamingChat, collaboration, gamingInfrequent updates (< 1/min)

Full Example: Live Dashboard

A server that pushes system metrics every second, with client resumption support:

// app/routes/api/metrics.ts
import { eventStream } from '@cmj/juice/runtime';

let counter = 0;

export function GET(req: Request) {
  // Resume from where the client left off
  const lastId = Number(req.headers.get('Last-Event-ID') ?? '0');
  counter = Math.max(counter, lastId);

  return eventStream(req.signal, (send) => {
    const interval = setInterval(() => {
      counter++;
      send({
        id: String(counter),
        event: 'metrics',
        data: {
          cpu: Math.random() * 100,
          memory: Math.random() * 16_000,
          requests: counter,
          timestamp: Date.now(),
        },
      });
    }, 1000);

    return () => clearInterval(interval);
  });
}
// app/routes/dashboard.tsx (client component)
'use client'
import { useState, useEffect } from 'react';

export default function MetricsDashboard() {
  const [metrics, setMetrics] = useState(null);
  const [connected, setConnected] = useState(false);

  useEffect(() => {
    const source = new EventSource('/api/metrics');

    source.addEventListener('metrics', (e) => {
      setMetrics(JSON.parse(e.data));
      setConnected(true);
    });

    source.onerror = () => setConnected(false);

    return () => source.close();
  }, []);

  if (!metrics) return <p>Connecting to metrics stream...</p>;

  return (
    <div>
      <p>{connected ? 'Live' : 'Reconnecting...'}</p>
      <dl>
        <dt>CPU</dt><dd>{metrics.cpu.toFixed(1)}%</dd>
        <dt>Memory</dt><dd>{(metrics.memory / 1000).toFixed(1)} GB</dd>
        <dt>Requests</dt><dd>{metrics.requests}</dd>
      </dl>
    </div>
  );
}