Server-Sent Events & WebSockets
Push live updates to the browser with first-party Server-Sent Events helpers — createEventStream on the server, useEventSource in components — and wire WebSockets per adapter.
This page covers streaming data to a rendered page. For streaming HTML and deferred Suspense boundaries, see Streaming SSR.
Server-Sent Events
For server→client streaming — live dashboards, progress updates, notification
feeds, LLM token streams — Server-Sent Events work on every adapter
without platform-specific code: the response is ordinary HTTP that never ends.
The browser side is plain EventSource, which reconnects automatically.
Reach for WebSockets only when the client also needs to push a
continuous stream of messages; for occasional client→server writes, a normal
API POST next to an SSE stream is simpler and works everywhere.
The API route
createEventStream(request, init?) from @pracht/core/server returns the
Response to hand back plus send and close:
import type { BaseRouteArgs } from "@pracht/core";
import { createEventStream } from "@pracht/core/server";
export function GET({ request }: BaseRouteArgs) {
const stream = createEventStream(request, { keepAlive: 15 });
let tick = 0;
const timer = setInterval(() => {
tick += 1;
const delivered = stream.send({
data: { now: new Date().toISOString(), tick },
event: "tick",
id: String(tick),
});
// send() returns false once the client is gone — stop producing.
if (!delivered) clearInterval(timer);
}, 1000);
return stream.response;
}What the helper takes care of:
- Wire format.
send({ data, event?, id?, retry? })serializes the SSE frame: strings pass through, everything else isJSON.stringifyed. - Disconnect cleanup. When the client disconnects, on any adapter,
send()starts returningfalse. Use that as your producer's stop condition. - Headers.
Content-Type: text/event-stream, plusCache-Control: no-store, no-transformandX-Accel-Buffering: noso caches and buffering proxies (nginx and friends) leave the stream alone. - Proxy idle timeouts.
keepAlive: 15emits a:keep-alivecomment every 15 seconds so load balancers with idle timeouts keep the connection open.
Try it with curl (-N disables curl's own buffering):
curl -N http://localhost:5173/api/live
# event: tick
# id: 1
# data: {"now":"2026-08-12T09:30:00.000Z","tick":1}
#
# event: tick
# id: 2
# ...The helper applies no backpressure: messages sent faster than the client
reads them buffer without bound. That is fine for event feeds. A high-volume
producer should watch stream.desiredSize (remaining queue capacity, null
once closed) and pause or drop messages while it is zero or negative:
const size = stream.desiredSize;
if (size === null) break; // stream closed — stop producing
if (size <= 0) continue; // consumer stalled — drop this frame
stream.send({ data: frame });Two more things worth knowing before you ship an SSE endpoint:
Producer lifetime is yours. The handler returns
stream.responseimmediately, and wrapping middleware finishes then too while the stream stays open. Stop your producer whensend()returnsfalse(or onstream.closed), not when middleware finishes. For anything long-lived, rely onrequest, not the handler'ssignalargument: that signal is a request-phase timeout.Resuming after reconnects. The browser replays the last
id:it saw in aLast-Event-IDrequest header when it reconnects. Send meaningful ids and read the header to resume instead of restarting:const lastEventId = request.headers.get("last-event-id"); // Replay everything after lastEventId, then continue live.
The component
useEventSource(url, options?) wraps EventSource: it connects on mount,
disconnects on unmount, tracks connection state, and optionally JSON-parses
payloads. Pass null as the URL to stay disconnected and clear the last
payload, for example until the user is signed in.
import { useEventSource } from "@pracht/core";
export function Component() {
const { data, status } = useEventSource<{ now: string; tick: number }>("/api/live", {
event: "tick", // listen for the named event; omit for unnamed messages
json: true,
});
return (
<section>
<p>Connection: {status /* "connecting" | "open" | "closed" */}</p>
<p>{data ? `tick ${data.tick} at ${data.now}` : "waiting for the first event"}</p>
</section>
);
}The browser reconnects dropped connections itself (tune the delay by sending
retry:), so status may bounce between "open" and "connecting". During
SSR the hook renders { status: "connecting" } and never connects. Changing
url or the options starts a fresh subscription, resetting data and
lastEventId to undefined.
Each useEventSource call opens its own connection. Over HTTP/1.1, browsers
allow about 6 connections per origin, shared with every other request, so a
few SSE subscriptions can starve the page. Lift a shared subscription into a
parent (context or props), and serve production traffic over HTTP/2 or 3.
The working example lives in the repo's examples/basic app: route /live,
endpoint src/api/live.ts.
WebSockets
A WebSocket upgrade is request handling with a platform-specific ending, so
where it lives depends on the adapter. The framework ships one shared helper —
isUpgradeRequest(request) from @pracht/core/server — and a same-origin
guard: browsers do not apply CORS to WebSocket, so pracht blocks cross-origin
upgrade requests by default (api.requireSameOrigin).
Cloudflare
The Cloudflare adapter serves upgrades through pracht routing: an API
route returns the 101 handshake response and the runtime passes it through
untouched. For connection state, forward to a Durable Object:
import type { BaseRouteArgs } from "@pracht/core";
import { isUpgradeRequest } from "@pracht/core/server";
interface Env {
CHAT_ROOM: DurableObjectNamespace;
}
export function GET({ request, context }: BaseRouteArgs<{ env: Env }>) {
if (!isUpgradeRequest(request)) {
return new Response("Expected a WebSocket upgrade", { status: 426 });
}
const room = context.env.CHAT_ROOM.get(context.env.CHAT_ROOM.idFromName("lobby"));
return room.fetch(request);
}import { DurableObject } from "cloudflare:workers";
export class ChatRoom extends DurableObject {
override fetch(_request: Request): Response {
const { 0: client, 1: server } = new WebSocketPair();
this.ctx.acceptWebSocket(server); // hibernation-friendly
return new Response(null, { status: 101, webSocket: client });
}
override webSocketMessage(_ws: WebSocket, message: string | ArrayBuffer) {
for (const peer of this.ctx.getWebSockets()) peer.send(String(message));
}
}Upgrades work in pracht dev the same way they do in production. See the
Adapters reference for the wrangler wiring.
Node
Node's http.Server delivers upgrade requests to its upgrade event, never
to the request handler, so a handshake structurally cannot reach pracht.
Attach a WebSocket server (e.g. ws)
alongside pracht instead. The Node adapter's configureServerFrom option
hands you the underlying http.Server before listen():
nodeAdapter({
configureServerFrom: "/src/server/websockets.ts",
});import type { Server } from "node:http";
import { WebSocketServer } from "ws";
export function configureServer(server: Server) {
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
// Browsers do not apply CORS to WebSocket, and this path never reaches
// pracht's own same-origin guard — check Origin yourself or any page on
// the web can open an authenticated socket (cross-site hijacking).
const origin = req.headers.origin;
if (origin !== process.env.PRACHT_ORIGIN) {
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
});
wss.on("connection", (ws) => {
ws.on("message", (message) => ws.send(String(message)));
});
}configureServer may be async; it runs when the generated entry is the
process entrypoint. If you import handler and build the server yourself,
attach the listener the same way on your own createServer(handler).
Vercel
The Vercel adapter cannot serve WebSocket upgrades (serverless and edge functions terminate them upstream). Use Server-Sent Events for server→client streaming, or a hosted realtime service for bidirectional messaging.