WebSockets
When you need a genuinely two-way connection, where both the browser and the server can send messages at any time, WebSockets are the tool. Fast implements RFC 6455 and, like SSE, exposes it as a simple route whose handler runs in its own fiber.
An echo server
The classic example: receive a message, send it back. ws() registers the route, and your handler loops on receive():
use Fast\Http\WebSocket\WebSocket;
ws('/echo', function (WebSocket $ws): void {
while (($msg = $ws->receive()) !== null) {
$ws->send('echo: ' . $msg);
}
});
receive() blocks this fiber until the next message arrives, and returns null when the connection closes, which is your cue to leave the loop. As always, the event loop keeps serving everyone else while this fiber waits.
The WebSocket API
The connection object handles the protocol details for you:
$ws->receive(); // next message as a string, or null when closed
$ws->send($data); // send a text message
$ws->send($bytes, binary: true); // send a binary frame
$ws->ping(); // send a ping
$ws->close($code, $reason); // close with a status code and reason
Fast transparently reassembles fragmented messages, answers pings, and handles the closing handshake, so receive() and send() are usually all you touch. Oversize or malformed frames are rejected with the correct close code.
Broadcasting to many clients
For chat-style fan-out, Fast\Http\WebSocket\Hub tracks a set of connections and broadcasts to all of them:
$hub = new Fast\Http\WebSocket\Hub();
ws('/chat', function (WebSocket $ws) use ($hub): void {
$hub->add($ws);
try {
while (($msg = $ws->receive()) !== null) {
$hub->broadcast($msg);
}
} finally {
$hub->remove($ws);
}
});
There are two things to understand about the Hub before you lean on it:
The Hub broadcasts only within a single worker. Because Fast spreads incoming connections across workers, two chatters may land on different workers and never see each other. For a simple chat, run one worker. For cross-worker fan-out, use Live Streams, which are built for exactly this.
The second caveat is about speed: broadcast() sends to each connection inline, one at a time, in a plain loop. A single slow client (one whose kernel send buffer is full) stalls the broadcasting fiber until that write completes or times out. So a broadcast is only as fast as its slowest recipient. For small rooms this is fine; for large ones, be aware of it.
Origin checks and security
WebSockets do not follow the same-origin policy the way fetch does, so Fast guards the handshake. Foreign browser origins are denied by default. A missing Origin header is allowed, since non-browser clients often omit it, but a foreign, malformed, or literal null origin is rejected.
When your app sits behind a TLS proxy, preserve the original Host header so the default same-authority check works. If Host cannot represent the real browser authority, configure an explicit allowlist:
ws('/partner', function (WebSocket $ws): void {
// ...
}, origins: ['https://app.example.com']);
Use origins: '*' only when you genuinely want a cross-site socket open to any origin, and understand what that means.
Writes are serialized
All writes on a connection go through a per-connection lock, so if several fibers send to the same socket, their frame bytes never interleave. Waiting writers are bounded, and Fast does not implement permessage-deflate compression.
For pushing to browsers from anywhere in your app, across every worker, the last Live topic is Live Streams.