[]previous and next page

Real time

Choose between typed streaming server functions, server-sent events and WebSocket routes, and configure their auth, limits, heartbeats and scale-out.

For agentsUpdated

Choose a server-function stream for a finite sequence, server-sent events for a one-way connection, or WebSockets for messages in both directions. Configure connection limits and cleanup alongside the route.

Start with Live data.

Choose a transport#

Use a timeline model to compare waiting for data with streaming the page shell first.

Explore streaming

You need Use Why
One answer per call, or a short list of results A server function Typed, no route file, error mapping included
A live feed the server pushes, reads only from the server Server-sent events (SSE route) Plain HTTP, reconnects by itself with Last-Event-ID, works where sockets are not available
Two-way traffic in one connection, such as chat or presence WebSocket (WS route) One socket carries messages in both directions
Chat, presence, live dashboards: named channels, resume after a reconnect, more than one server @shadow-js/realtime Built on the WS and SSE routes below; adds channels, rules, numbered messages, presence and shared storage
A typed stream of values from a server function, cancelable from the page Typed streaming server function Same types as the function, bounded by time and item count

Server-sent events go one way, from server to browser. The browser reconnects on its own and sends the last id it saw. A WebSocket connection is two-way but has no automatic replay: your code must send missed messages after a reconnect. A typed stream is started by the page, ends when the function returns, and does not reconnect on its own.

Typed streaming server functions#

A server function becomes a stream when you pass stream: true. The handler is an async generator or returns an AsyncIterable. Each yielded value is one item on the wire.

src/ticks.tsts
import { serverFn } from "@shadow-js/dusk/server";

export const ticks = serverFn({ stream: true }, async function* () {
  for (let index = 1; index <= 3; index += 1) {
    yield index;
  }
});

Typed streams need the rpc feature with streams enabled. Without it the function throws at startup: typed streams need rpc({ streams: {} }). Limits for a stream are set on that feature, and stream route limits are set on createHandler.

src/entry-server.tsts
import type { HandlerOptions } from "@shadow-js/dusk/server";
import { rpc } from "@shadow-js/dusk/features/rpc";
import { streams } from "@shadow-js/dusk/features/streams";

const options: Omit<HandlerOptions, "manifest"> = {
  features: [rpc({ streams: { maxDurationMs: 60_000, maxItems: 5_000 } }), streams()],
  streams: { sseHeartbeatMs: 15_000, wsMaxMessageBytes: 64 * 1024, wsMaxConnections: 5_000 },
};

export default options;

Defaults and what happens at each limit:

Option Default When reached
maxDurationMs (rpc({ streams })) 30,000 The stream ends with RequestTimeoutError
maxItems (rpc({ streams })) 10,000 The next item fails with RangeError("SERVER_FN_STREAM_ITEM_LIMIT")

The wire format has three frames: item carries one value, done ends the stream, and error carries a failure after items have started. An error before the first item uses the ordinary RPC failure mapper, so the response looks like any other server function failure. The limits are enforced on the server. A client does not get to extend them.

On the client, useStream from @shadow-js/dusk/client/stream turns a stream source into reactive state. It is not exported from @shadow-js/dusk/client/index. The result exposes three accessors and one method: value(), error(), status() (one of "pending", "streaming", "complete" or "error"), and reconnect().

src/feed.tsts
import { useStream } from "@shadow-js/dusk/client/stream";
import { ticks } from "./ticks";

const feed = useStream((signal) => ticks(undefined, { signal }));

// Read the accessors as functions: feed.value(), feed.status(), feed.error().
// feed.reconnect() aborts the running call and starts a fresh one.

Three rules to remember:

  • Cancellation: an optional last argument { signal } on the client stub cancels that one call. Aborting the signal stops the request. useStream passes the signal for you, and it aborts when the reactive owner is disposed or when reconnect() is called.
  • No automatic retry: when a stream fails, status() becomes "error" and nothing restarts it. Call reconnect() yourself, and only when the user or your code decides to retry. reconnect() never replays items that already arrived.
  • The client decoder has its own guards: 65,536 bytes per frame, 1,048,576 bytes per chunk, 10,000 items and a 4,096-byte decode window. These protect the page from a broken or hostile server response. They are not server limits, and they do not change what the server sends.

Server-sent events#

An SSE export answers a GET with an event stream. The handler receives the request and a SseStream.

src/routes/ticks.tsts
import { createEventLog, type RequestEvent, type SseStream } from "@shadow-js/dusk/server";

const log = createEventLog({ capacity: 1000 });
const listeners = new Set<(message: { readonly id: string; readonly data: unknown }) => void>();

export function publishPrice(value: number): void {
  const id = log.append({ event: "price", data: { value } });
  for (const listener of listeners) listener({ id, data: { value } });
}

export function SSE(event: RequestEvent, stream: SseStream): void {
  void event;
  // Replay what the reconnecting client missed. `undefined` means the id is gone: the client must resync.
  for (const missed of log.since(stream.lastEventId) ?? []) {
    stream.send({ id: missed.id, event: "price", data: missed.data }).catch(() => undefined);
  }
  const listener = (message: { readonly id: string; readonly data: unknown }): void => {
    stream.send({ id: message.id, event: "price", data: message.data }).catch(() => undefined);
  };
  listeners.add(listener);
  stream.signal.addEventListener("abort", () => listeners.delete(listener), { once: true });
}

What the stream does for you:

  • stream.send(message) returns a promise that resolves when the browser has taken the event. A producer that awaits each send is paced by the consumer.
  • A heartbeat comment (: ping) is written every 15 seconds by default (sseHeartbeatMs). Heartbeats are keep-alives, not data.
  • stream.comment(text) writes a comment. The text is limited to 1024 characters (SSE_MAX_COMMENT_CHARS).
  • An encoded event is limited to 64 KiB (SSE_MAX_EVENT_BYTES). The encoder throws a RangeError for a larger one (server/sse.ts:121), so keep payloads small and send a reference or a fetch URL for big data.
  • stream.signal aborts when the client disconnects, when the stream closes, or when it fails. Remove listeners on abort.
  • stream.closed is true once the stream has ended for any reason.
  • stream.lastEventId is the Last-Event-ID header the browser sent on reconnect.

Backpressure: events wait for a slow consumer, up to sseMaxBufferedEvents (default 256). Past that, the stream is closed and the pending send rejects with SseOverflowError (code is "SSE_OVERFLOW"). Heartbeats and comments are droppable: while a chunk is already waiting, they are skipped rather than queued. Handle the rejection, as the example does, or log it.

Replay: createEventLog({ capacity }) keeps the last events in memory, with a default capacity of 1000. log.append returns the id it gave the event. log.since(id) returns the events after id, in order, or undefined when the log no longer holds that id. The log lives in one server instance (see Scaling out).

WebSocket routes#

A WS export takes WebSocket upgrades. Its handlers are upgrade, open, message and close. All are optional.

src/routes/chat.tsts
import type { WebSocketConnection, WebSocketHandlers } from "@shadow-js/dusk/server";

const lobby = new Set<WebSocketConnection>();

export const WS: WebSocketHandlers = {
  upgrade(event) {
    if (event.cookies.get("session") === undefined) return new Response(null, { status: 401 });
    return undefined;
  },
  open(ws) {
    lobby.add(ws);
  },
  message(ws, message) {
    if (typeof message !== "string") {
      ws.close(1003, "text only");
      return;
    }
    for (const peer of lobby) peer.send(message);
  },
  close(ws) {
    lobby.delete(ws);
  },
};

Auth on upgrade#

upgrade(event) runs before the handshake is answered. Return a Response to refuse the connection, for example a 401 when the session cookie is missing. Return nothing to accept it. The event carries the request cookies, so you can read the session there (event.cookies.get, server/cookies.ts). Put what the connection needs into ws.data in open.

Run your own authorization again inside open or message when a permission can change during the connection. The upgrade check happens once.

Origin checks#

Browsers send an Origin header on WebSocket upgrades. Dusk compares it with the request host and then with csrf.trustedOrigins. The rule is:

  • No Origin header: refused unless csrf.allowMissingOrigin is set.
  • An Origin that is neither the same origin nor in csrf.trustedOrigins: refused with 403.

Set csrf.trustedOrigins for any other front end that opens your socket. Do not turn off the check to make a cross-origin client work.

The upgrade decision#

Dusk decides an upgrade in this order, before a socket exists:

  1. 414 for a URL that is too long.
  2. 405 for a method other than GET.
  3. 400 for a Host header that fails the strict Host check on Node.
  4. 400 for a handshake missing the Upgrade or Connection headers, or with a malformed Sec-WebSocket-Key.
  5. 426 for a wrong WebSocket version. Only version 13 is accepted, and the response carries sec-websocket-version: 13. A plain GET on a WebSocket-only route also gets 426.
  6. 403 for the origin check above.
  7. 404 when the route does not exist.
  8. 503 with retry-after: 5 when a connection cap is reached (per address or for the whole handler).
  9. App middleware.
  10. WS.upgrade.

The whole decision is bounded by limits.requestTimeoutMs. A decision that runs past it gets 504.

Connection methods#

  • ws.send(message) sends a string as text and bytes as binary. It returns false and sends nothing once the connection is closed. Check the return value when a peer may have left.
  • ws.close(code?, reason?) closes with a code (default 1000). The reason is at most 123 UTF-8 bytes.
  • ws.readyState is "open" until the server sends a close frame or the transport ends. It then stays "closed".
  • ws.signal aborts when the connection closes. Use it to end timers and subscriptions you start in open.

Message size, rate and concurrency#

Each limit closes the connection with a fixed code:

Condition Close code Default
Client message larger than wsMaxMessageBytes 1009 1 MiB
More than wsMaxMessagesPerSecond client messages in one second 1008 100
More than wsMaxInFlightMessages message calls still pending (the message that exceeds the limit is not handled) 1013 16
message or open handler throws (open and message failures) 1011 not configurable
Unsent data waiting for a peer that stopped reading, over maxBufferedBytes (default 8 times wsMaxMessageBytes) 1013 (slow consumer) 8 MiB

Only message handler calls that return a promise that has not settled count toward the in-flight limit. Synchronous handlers never do.

Heartbeats#

The server pings each connection every 30 seconds (wsPingIntervalMs) and waits 10 seconds for the pong (wsPongTimeoutMs). A connection whose pong does not arrive ends without a close frame, and its close handler receives 1006. You do not write ping code. Make sure your proxy and load balancer do not close idle connections faster than 30 seconds.

Shutdown#

When the process gets one of the shutdown signals, the Node server stops accepting connections, lets requests in flight finish for up to gracefulShutdownMs (default 10,000), and then exits. The signals are SIGTERM and SIGINT by default. Change them with shutdownSignals, or pass false to register none.

While draining, the Bun and Deno sockets answer new upgrade requests with 503 and retry-after: 1. The Node server does the same with retry-after: 1. Clients should reconnect after that delay.

Open WebSocket connections are closed with code 1001 and the reason server shutdown. Clients should treat 1001 as a planned restart and reconnect with backoff. Do not treat it as an error to show the user.

Adapter support#

Not every adapter supports every transport. Support is generated from the adapter capability table, so it is not copied here. Read the table in Deploy before you commit to a WebSocket route, SSE route or typed stream for a host.

Scaling out#

Each Dusk instance keeps its own state in memory:

  • Each WebSocket connection, its ws.data, and the in-process subscriber sets in your code.
  • Each createEventLog. A reconnect that lands on another instance can miss replay data.
  • The per-address and whole-handler connection counts.

Dusk core does not fan messages out across instances: a message sent on instance A does not reach a client connected to instance B. For chat, presence and live feeds that must work on more than one instance, use @shadow-js/realtime: its channels keep one numbered log per channel in a shared store (Redis, Valkey or Cloudflare Durable Objects), deliver to clients on every instance in order, and resume a client that reconnects to a different instance. If you wire your own broker instead:

  1. Pick an external broker that every instance can reach (a managed pub/sub service, Redis, NATS, or similar).
  2. Publish each message to the broker from the instance that produced it.
  3. Each instance subscribes once at startup and forwards broker messages to its own local connections or event streams.
  4. Use the broker's own sequence or stream offset as the SSE id, so Last-Event-ID means the same thing on every instance. Keep the replay log in the broker, not in createEventLog, when a reconnect can land anywhere.

Put the instances behind a load balancer that keeps the long-lived connections open and passes the client address through. Set trustProxy so the per-address connection cap counts real clients and not the proxy.

Dusk itself has no cross-instance helper; the package above is the supported answer.