Heximon Logo
Realtime

Overview

Choosing between EventStream server-sent events and a WebSocketHandler socket, the opt-in typed layer over WebSocketContract and ServerSentEvents classes, and when Durable Objects earn their keep for cross-isolate broadcast.

Some responses can't be a single JSON payload — the server has more to say after it's already replied, or the client needs to talk back on the same connection. Heximon covers both with a handler class and one config key: no separate server to run, no Durable Object required to get started.

Start with plain Node — no Durable Objects needed

A server-sent-events stream is a value you return from a controller, and a WebSocket handler is a class with open/message/close methods bound to a path. Both run on the same fetch host your HTTP routes already use, and both work fully in-process on Node: one server, one port, real bidirectional or push traffic, zero platform-specific concepts.

src/echo/echo.socket.ts
import type { Inbound, WebSocketConnection, WebSocketHandler } from "@heximon/websocket";

export class EchoSocket implements WebSocketHandler<"/ws/echo"> {
  public async open(connection: WebSocketConnection): Promise<void> {
    connection.send(JSON.stringify({ type: "welcome", id: connection.id }));
  }

  public async message(connection: WebSocketConnection, inbound: Inbound): Promise<void> {
    connection.send(inbound.text());
  }

  public async close(_connection: WebSocketConnection): Promise<void> {}
}

That's the entire mental model for a single-server app: pick a transport, author the handler, list it in your module. Everything past this point is opt-in, and each tier only matters once your app actually needs it.

SSE or WebSockets?

The two transports solve different shapes of "the response isn't one message." Pick by whether the client ever needs to talk back on the same connection.

Server-Sent EventsWebSockets
DirectionServer → client onlyBidirectional
ProtocolPlain HTTP (text/event-stream)Upgraded connection
Browser APIBuilt-in EventSource, auto-reconnectWebSocket, you handle reconnect
Resume after a dropLast-Event-ID, built inNothing built in — reconnect and re-sync yourself
Reach for it whenProgress updates, notifications, live feedsChat, presence, collaborative editing, anything the client sends often

If the client only ever receives, SSE is less to build and gives you resume for free. The moment the client needs to send more than an occasional request, reach for a socket instead.

See Server-Sent Events and WebSockets for the full handler walkthrough — validation, auth at upgrade, and the module wiring.

Add a typed contract when the shape matters

A plain socket sends and receives untyped frames; a plain stream sends untyped events. Once your protocol has a fixed vocabulary of message kinds worth getting wrong at compile time instead of in production, declare it as a class — a WebSocketContract({ clientToServer, serverToClient }) for a socket, or a ServerSentEvents({ events }) map for a stream — and both your server and a matching frontend client validate against the same schemas.

This is a layer on top, not a prerequisite: every example above works with no contract at all.

See WebSocket Contracts for binding a WebSocketContract to a socket handler, and Typed clients for the frontend-safe WsClient / SseClient pair.

Durable Objects: the Cloudflare scale-out story

Everything so far runs in one process. That's fine until clients of the same room or topic land on different isolates on Cloudflare — at that point no single process holds every connection, so a plain publish or broadcast can't reach them all. Durable Objects solve that by giving one room a single, addressable, stateful home: a DurableObject holds every connection for a topic, regardless of which isolate accepted them.

You may never need this. It's a Cloudflare-specific mechanism for cross-isolate fan-out, not a requirement for realtime in general — a single Node server never has this problem, because one process already holds every connection. When you don't need cross-isolate broadcast, skip this tier entirely.

When you do, pnpm dev still runs a faithful in-process shim, so you build and test the whole flow before you ever touch real Cloudflare infrastructure.

Documenting it: AsyncAPI

Once you've described your realtime API as a WebSocketContract or a ServerSentEvents map, that description doubles as documentation: AsyncAPI generates a standards-compliant document straight from the same classes, so the docs can't drift from what the wire actually validates.

Next steps

  • WebSockets — author a bidirectional handler, validate inbound frames, and authenticate at upgrade.
  • WebSocket Contracts — the opt-in typed layer: a WebSocketContract class with per-kind message dispatch and broadcast transports.
  • Server-Sent Events — stream typed events with heartbeats and Last-Event-ID resume.
  • Typed clients — the frontend-safe WsClient / SseClient pair.
  • Durable Objects — the cross-isolate broadcast tier, and its Node dev shim.
  • AsyncAPI — generate a spec document from your existing contracts.
Copyright © 2026