Real time
Choose between typed streaming server functions, server-sent events and WebSocket routes, and configure their auth, limits, heartbeats and scale-out.
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.
| 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.
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.
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().
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.useStreampasses the signal for you, and it aborts when the reactive owner is disposed or whenreconnect()is called. - No automatic retry: when a stream fails,
status()becomes"error"and nothing restarts it. Callreconnect()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.
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 aRangeErrorfor a larger one (server/sse.ts:121), so keep payloads small and send a reference or a fetch URL for big data. stream.signalaborts when the client disconnects, when the stream closes, or when it fails. Remove listeners on abort.stream.closedistrueonce the stream has ended for any reason.stream.lastEventIdis theLast-Event-IDheader 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.
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
Originheader: refused unlesscsrf.allowMissingOriginis set. - An
Originthat is neither the same origin nor incsrf.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:
- 414 for a URL that is too long.
- 405 for a method other than GET.
- 400 for a Host header that fails the strict Host check on Node.
- 400 for a handshake missing the
UpgradeorConnectionheaders, or with a malformedSec-WebSocket-Key. - 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. - 403 for the origin check above.
- 404 when the route does not exist.
- 503 with
retry-after: 5when a connection cap is reached (per address or for the whole handler). - App middleware.
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 returnsfalseand 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.readyStateis"open"until the server sends a close frame or the transport ends. It then stays"closed".ws.signalaborts when the connection closes. Use it to end timers and subscriptions you start inopen.
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:
- Pick an external broker that every instance can reach (a managed pub/sub service, Redis, NATS, or similar).
- Publish each message to the broker from the instance that produced it.
- Each instance subscribes once at startup and forwards broker messages to its own local connections or event streams.
- Use the broker's own sequence or stream offset as the SSE
id, soLast-Event-IDmeans the same thing on every instance. Keep the replay log in the broker, not increateEventLog, 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.