WebSockets — Persistent Connections, Authentication, and Scaling
Understand the WebSocket connection lifecycle and the event-loop-based server model (as used by Ratchet and Swoole) that PHP needs to hold many long-lived connections at once; authenticate a WebSocket connection given that cookies and headers behave differently than in normal HTTP; and scale WebSockets across multiple server processes using a shared pub/sub layer, including tracking and cleaning up dead connections.
WebSockets give you what SSE deliberately gives up: a single, persistent, full-duplex connection where either side -- server or client -- can send a message at any moment, without opening a new HTTP request each time. That makes them the right tool for chat, multiplayer state, collaborative editing, and anything else where the client's own actions need to reach the server in real time over the same channel it's receiving updates on. It also means PHP has to run in a fundamentally different way than a normal request-per-process PHP-FPM worker: a WebSocket server holds thousands of connections open simultaneously in one long-running process, which is where the event-loop model and its specific scaling and authentication challenges come from.
Connection Lifecycle and Event-Loop Servers
A WebSocket connection starts as a normal HTTP request carrying an Upgrade: websocket header; the server responds with 101 Switching Protocols and from that point on, the TCP connection is repurposed to carry WebSocket frames instead of further HTTP requests, staying open until either side closes it or the network drops. Unlike a typical PHP-FPM request -- spin up, handle one request, tear down -- a WebSocket server process has to stay alive and keep tracking potentially thousands of these open connections simultaneously.
Standard synchronous PHP-FPM cannot do this: one worker process blocked handling one connection can't also service the other 999 connections it's supposed to be holding open. Event-loop-based servers solve this by never blocking on I/O -- a single process (or a small pool of them) runs a loop that watches ALL open connections at once and reacts to whichever one has an event (new message, disconnect) ready, handling many connections concurrently within one process without needing one process per connection. Ratchet is a pure-PHP library built on ReactPHP's event loop; Swoole (and its newer sibling OpenSwoole) is a PHP extension providing a much faster, coroutine-based event loop written in C, and is the more common choice for high-connection-count production WebSocket servers in PHP today.
• Handshake: normal HTTP request with Upgrade: websocket -> server replies 101 Switching Protocols -> connection repurposed for WebSocket frames. • A WebSocket server process must hold many connections open simultaneously -- incompatible with PHP-FPM's one-process-per-request model. • Event-loop servers never block on I/O; one process reacts to whichever of many open connections has something ready. • Ratchet (pure PHP, ReactPHP-based) and Swoole/OpenSwoole (C extension, coroutine-based, faster) are the two common PHP approaches.
Authenticating a WebSocket Connection
A normal HTTP request re-sends its cookies and auth headers on every single request, but a WebSocket connection authenticates ONCE, at the handshake, and then stays open indefinitely under whatever identity was established then -- there's no natural point afterward to re-check credentials the way a fresh request would. Browsers DO send cookies on the initial handshake request (it's a normal HTTP request at that point), so a session cookie can carry authentication through -- but the JavaScript WebSocket constructor cannot set custom headers, so a bearer token has to be passed some other way: as a query-string parameter on the handshake URL, or as the very first message sent immediately after the connection opens, since neither the Authorization header nor a custom header is available at handshake time from browser JavaScript.
Whichever mechanism is used, the server must validate identity during (or immediately after) the handshake and REJECT the upgrade if it fails, rather than accepting the connection and hoping a later check catches it -- and because the connection then stays open under that identity for a long time, a token that later expires or a user who gets banned mid-session needs an explicit mechanism (a periodic re-check, or a server-initiated close when the underlying session is revoked) since the connection itself won't naturally re-authenticate again on its own.
• Auth happens ONCE at handshake, then the connection stays open under that identity indefinitely -- unlike per-request HTTP auth. • Browser JS's WebSocket constructor cannot set custom headers -- pass a token via query string or as the first message after connecting. • Reject the upgrade outright on failed auth; don't accept the connection and check later. • A long-lived connection needs an explicit mechanism to react to later revocation/expiry (periodic re-check or server-initiated close) since it won't naturally re-authenticate.
Scaling Across Processes with a Shared Pub/Sub Layer
A single event-loop process can hold many connections, but it's still one process on one machine -- real scale needs multiple WebSocket server processes (often across multiple machines), and that creates a real problem: if user A is connected to process 1 and user B (who needs to receive a message A sends) is connected to process 2, process 1 has no direct way to reach a connection sitting in process 2's memory.
The standard fix is a shared pub/sub layer -- Redis Pub/Sub is the most common choice -- sitting between all the WebSocket processes: when process 1 needs to deliver a message to a connection it doesn't hold, it publishes the message to a shared channel instead; every WebSocket process subscribes to that channel, and whichever one actually holds the target connection (in this case, process 2) picks up the message and forwards it down its own local connection. This decouples 'which process generated this message' from 'which process holds the connection it needs to reach', letting the WebSocket layer scale horizontally the same way a stateless HTTP API does.
• Multiple WebSocket processes can't reach each other's in-memory connections directly. • Shared pub/sub (commonly Redis) lets any process publish a message that whichever process holds the target connection can pick up and forward. • This decouples message origin from connection ownership, enabling horizontal scaling across many processes/machines.
Connection Management: Tracking and Cleaning Up Dead Connections
Network connections don't always close cleanly -- a laptop goes to sleep, WiFi drops, a mobile client loses signal -- and the server-side TCP connection can be left in limbo, looking open to the server's connection tracking even though the client is long gone. Left unhandled, these 'zombie' connections accumulate memory and, worse, make broadcast/pub-sub delivery waste effort trying to write to sockets nobody is reading.
The standard fix is a heartbeat: the server periodically sends a lightweight ping frame to every open connection and expects a pong back within a timeout; a connection that misses enough consecutive pongs is assumed dead and is explicitly closed and removed from the server's connection registry. Every connect and disconnect (clean or heartbeat-detected) should also update the connection-tracking data structure (and any shared registry, like a Redis set of 'which user is on which server process', used by the pub/sub layer above) so the rest of the system's view of 'who is currently connected' stays accurate.
• Connections can die without a clean close (sleep, WiFi drop) -- the server can't always detect this just by waiting for a close frame. • Heartbeat: periodic ping frames, expect a pong within a timeout, close and remove a connection that misses enough consecutive pongs. • Keep the connection registry (in-process and any shared cross-process registry) updated on every connect/disconnect so the system's view of 'who's connected' stays accurate.
Conceptual Ratchet Server Shape
onOpen is the handshake point -- authentication and rejection must happen here, not after accepting the connection. SplObjectStorage tracks live connections (and per-connection metadata) so onClose can clean up precisely. This example only broadcasts within ONE process's connections -- reaching connections on other processes needs the pub/sub layer.
final class ChatServer implements MessageComponentInterface
{
private \SplObjectStorage $connections;
public function __construct()
{
$this->connections = new \SplObjectStorage();
}
public function onOpen(ConnectionInterface $conn): void
{
// Authenticate here, at handshake time, before accepting
$token = $this->extractToken($conn->httpRequest);
if (!AuthService::verify($token)) {
$conn->close(); // reject the upgrade
return;
}
$this->connections->attach($conn, ['user_id' => AuthService::userIdFor($token)]);
}
public function onMessage(ConnectionInterface $from, string $msg): void
{
foreach ($this->connections as $conn) {
if ($conn !== $from) {
$conn->send($msg); // broadcast to everyone else on THIS process
}
}
}
public function onClose(ConnectionInterface $conn): void
{
$this->connections->detach($conn); // clean up on disconnect
}
}Code Examples
Since the browser's WebSocket API can't attach an Authorization header, the token rides in the handshake URL's query string instead; the server validates it during onOpen and closes the connection outright (with a policy-violation close code) on failure, rather than accepting it and checking later. ConnectionRegistry::register() also records which server process holds this user's connection -- exactly the piece of information the pub/sub layer needs to route a message TO this user from a different process.
$conn->close(1008, 'Unauthorized');
Common mistake: Passing a long-lived bearer token in the URL query string, which then ends up logged in plain text by any reverse proxy or load balancer access log sitting in front of the WebSocket server -- prefer a short-lived, single-use token minted specifically for the handshake.
The originating process publishes to a shared Redis channel without knowing or caring which process holds the target user's connection; every WebSocket process subscribes to the same channel and independently checks its own local connection registry, so only the one process that actually holds that user's socket forwards the message. Expected result: The message reaches the target user's browser regardless of which of N server processes their connection happens to be on
$redis->publish('ws:user:' . $userId, json_encode($message));
<?php
declare(strict_types=1);
function extractToken(\Psr\Http\Message\RequestInterface $request): ?string
{
// Browser JS's WebSocket constructor can't set an Authorization header,
// so the token typically arrives as a query parameter on the handshake URL:
// wss://api.example.com/chat?token=eyJhbGciOi...
parse_str($request->getUri()->getQuery(), $params);
return $params['token'] ?? null;
}
function onOpen(ConnectionInterface $conn): void
{
$token = extractToken($conn->httpRequest);
if ($token === null || !($claims = AuthService::verifyJwt($token))) {
$conn->close(1008, 'Unauthorized'); // 1008 = policy violation close code
return;
}
$conn->userId = $claims['sub'];
ConnectionRegistry::register($conn->userId, $conn, serverProcessId: getmypid());
}<?php
declare(strict_types=1);
// Called by whichever process originates the message (e.g. a REST endpoint
// or another WebSocket connection on a DIFFERENT process than the recipient)
function deliverToUser(int $userId, array $message): void
{
$redis = RedisFactory::client();
$redis->publish('ws:user:' . $userId, json_encode($message));
}
// Run inside every WebSocket server process at startup
function subscribeToOwnUsers(\Redis $redis, ConnectionRegistry $registry): void
{
$redis->subscribe(['ws:broadcast'], function (\Redis $redis, string $channel, string $payload) use ($registry) {
$message = json_decode($payload, true);
$userId = $message['user_id'];
// Only this process's registry knows if it actually holds that user's connection
$conn = $registry->connectionFor($userId);
if ($conn !== null) {
$conn->send($message['data']);
}
// If this process doesn't hold that user, it simply does nothing —
// whichever process DOES hold them will pick it up the same way.
});
}Scaling WebSockets with Redis pub/sub
Neither process needs to know where the other's connections live -- the shared channel routes for them.
Process A (holds User 1) Redis Pub/Sub Process B (holds User 2)
| | |
User 1 sends msg | |
for User 2 ------publish------>| |
|------deliver--------> matches User 2
| forwards over
| Process B's socketImportant Rules
Authenticate at the handshake (onOpen) and reject the upgrade outright on failure -- don't accept the connection and check later.
Never rely on custom request headers for browser WebSocket auth -- browser JS cannot set them; use a query-string token or a first-message handshake instead.
Never put a long-lived, sensitive token in a WebSocket URL's query string -- it ends up in proxy/load-balancer access logs; use a short-lived handshake-only token.
Use a shared pub/sub layer (commonly Redis) to deliver messages across server processes -- in-process connection state alone cannot reach another process's connections.
Run a heartbeat (ping/pong) and remove connections that miss it, keeping any shared connection registry in sync with reality.
Swoole in production PHP WebSocket servers
Swoole (and OpenSwoole) power a large share of production PHP real-time systems -- chat backends, live dashboards, multiplayer game state -- specifically because its coroutine-based event loop, written as a C extension, can hold tens of thousands of concurrent connections per process with far less overhead than a pure-PHP event loop like Ratchet's, while still letting application logic be written in ordinary-looking PHP. Teams running it at scale universally pair it with Redis (or an equivalent pub/sub broker) the moment they need more than one server process, for exactly the cross-process delivery reason covered above.
Accepting every WebSocket connection unconditionally in onOpen and checking authentication only when the first real message arrives.
Verify the token during onOpen itself and close the connection immediately (with a policy-violation code) if it's missing or invalid.
Accepting first and checking later gives an unauthenticated client a live connection into your server for however long it takes to send its first message, and complicates every other handler with an 'is this connection actually authenticated yet' check.
Broadcasting a message only to the connections a single WebSocket process holds locally, assuming that's 'everyone'.
Publish the message to a shared pub/sub channel so every server process -- including ones holding other recipients' connections -- can deliver it.
Once you run more than one WebSocket server process, a recipient's connection is very likely NOT on the same process as the sender's; local-only broadcast silently drops messages for anyone connected elsewhere.
Why can't a browser's WebSocket client typically send a bearer token via a custom Authorization header? A. WebSockets don't support any form of authentication B. The browser's WebSocket JavaScript API does not allow setting custom request headers on the handshake C. Bearer tokens are too large to fit in a WebSocket frame D. Servers reject all WebSocket connections that include an Authorization header
B. The browser's WebSocket JavaScript API does not allow setting custom request headers on the handshake — The browser's built-in WebSocket constructor has no option to set custom headers, so tokens are typically passed via the handshake URL's query string or as the first message after the connection opens.
Why does scaling a WebSocket service across multiple server processes require a shared pub/sub layer like Redis? A. Redis is required to encrypt WebSocket traffic B. Each process only holds its own connections in memory, so a message originating on one process needs a shared channel to reach a recipient's connection held by a different process C. PHP cannot run more than one WebSocket server process at all without Redis D. Pub/sub reduces the number of heartbeat pings needed
B. Each process only holds its own connections in memory, so a message originating on one process needs a shared channel to reach a recipient's connection held by a different process — A WebSocket connection lives in the memory of whichever single process accepted it; pub/sub lets any process publish a message that the process actually holding the target connection can pick up and forward, without the two processes needing to know about each other directly.
Design (in comments/pseudocode) a heartbeat mechanism for the ChatServer example: every 30 seconds, send a ping to every tracked connection; track a missed-pong counter per connection; if a connection misses 3 consecutive pongs, close it and detach it from $this->connections and any shared registry.
On a periodic timer: foreach ($this->connections as $conn) { $data = $this->connections[$conn]; $data['missedPongs']++; if ($data['missedPongs'] >= 3) { $conn->close(); $this->connections->detach($conn); ConnectionRegistry::unregister($data['user_id']); continue; } $conn->send(json_encode(['type'=>'ping'])); $this->connections[$conn] = $data; } And in onMessage, if the message type is 'pong', reset missedPongs to 0 for that connection.
Store a small metadata array per connection in SplObjectStorage (it supports attaching data alongside each object) tracking missedPongs, incremented on each ping-timer tick and reset to 0 whenever a pong is received.