WebSockets in PHP — Stop Zombie Connections in Ratchet
A single broken WebSocket handshake creates zombie connections—here's how to validate handshake and close gracefully in PHP Ratchet to avoid memory leaks..
20+ years shipping production PHP systems at scale. Notes here come from systems that actually shipped.
- WebSockets upgrade HTTP to full-duplex persistent TCP connections
- Ratchet provides a PHP implementation of WebSocket server and client
- The handshake upgrade request must be validated (key, version, origin)
- Zombie connections occur when handshake is malformed or client disconnects uncleanly
- A missed handshake validation leaves the server holding broken streams
- Heartbeat pings (close frame on timeout) are the only reliable cleanup
Imagine you're waiting for a pizza delivery. With normal HTTP, you'd have to call the restaurant every 30 seconds to ask 'Is my pizza ready yet?' — that's polling. WebSockets are like the restaurant handing YOU a walkie-talkie when you order. Now they can call YOU the instant your pizza is done, without you asking. Both sides can talk whenever they want, on a single open line, for as long as the conversation lasts.
WebSocket connections are supposed to be persistent. But in production, half-open connections silently accumulate—clients disconnect without closing the handshake, and the server holds stale sockets forever. Each zombie eats a port, a file descriptor, and a chunk of memory. You don't notice until your Ratchet server runs out of FDs at 3 AM. That's the real cost of skipping proper handshake validation. This article shows how to detect and close those zombies at the protocol level, not just at the application layer.
What is WebSockets in PHP?
WebSockets let you maintain a persistent, bidirectional communication channel between a client and server. In PHP, Ratchet is the most mature library, built on top of ReactPHP's event loop. Unlike traditional HTTP—which dies after each request—a WebSocket connection stays open after the upgrade handshake. That handshake is where most zombie problems start: if the server doesn't properly validate the client's Sec-WebSocket-Key and Sec-WebSocket-Version, it can end up with a half-baked connection that never sends or receives properly.
Handshake Validation & Upgrade
The WebSocket handshake is an HTTP upgrade request. The client sends GET /chat HTTP/1.1 with headers Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Key: base64-encoded 16 bytes, Sec-WebSocket-Version: 13. The server MUST respond with 101 Switching Protocols and a Sec-WebSocket-Accept header computed from the key. If any header is missing or the version isn't 13, the connection should be rejected immediately. Ratchet does this automatically, but you can intercept the handshake via middleware to add origin validation or rate limiting.
- Client proposes: key, version, protocols, extensions
- Server accepts or rejects with status code 101 or 4xx
- Once accepted, every subsequent byte is a WebSocket frame
- If the server doesn't validate, it can't parse frames correctly
Managing Connections and Rooms
In production, you need to group connections into rooms or channels for broadcasting. Ratchet provides a Topic abstraction in Ratchet\Wamp\WampServerInterface, but for raw WebSockets you'll manage your own data structure. A SplObjectStorage keyed by room name works, but be careful: removing dead connections is manual. Every onClose must remove the connection from all rooms it belongs to. Failure to do so leaks references, and the garbage collector won't save you—the SplObjectStorage holds a strong reference.
Why Your First WebSocket Server Will Leak Memory (and How to Fix It)
Every connection consumes resources. PHP's shared-nothing architecture means each WebSocket client holds memory until explicitly freed. The trap: forgetting to close connections after disconnect events. When a client drops (network timeout, tab close, crash), your server won't know until the next read attempt. That zombie connection keeps its buffer, socket, and room membership alive. The fix: implement a heartbeat ping-pong every 30 seconds. On missing two consecutive pongs, forcefully close the socket. Use
``php if ($lastPong < ``time() - 60) { $conn->close(); }
Also register a 'close' handler that immediately decrements room counters and frees any per-connection state. Test this with 100 concurrent clients and compare memory before/after. You'll see the difference in the first minute.
WeakReference or manual deletion. Never unset() inside a foreach — it corrupts the internal pointer.Broadcast to Rooms Without Blocking Your Event Loop
When you broadcast a message to 1000 clients in a room, don't loop sequentially. Each call blocks until the write buffer is full or the socket is ready. One slow client (e.g., mobile on 3G) holds up everyone behind it. Solution: use non-blocking writes with a write queue. Push messages into a per-connection buffer, then process them in batches during your main loop's write phase. PHP 8.x's fwrite()Swoole or ReactPHP handle this natively, but with raw sockets you must do it manually. Track each connection's write buffer size; if it exceeds 64KB, close the connection before it balloons into an OOM. Use and socket_set_nonblock() with a return check. This pattern turns a O(n) broadcast into O(1) per client.socket_write()
socket_set_send_buffer() to 128KB to avoid kernel buffer overflow. Monitor socket_last_error() for SOCKET_EWOULDBLOCK — it means the client can't keep up, not an error.The Zombie Overflow
$loop->addPeriodicTimer(30, function() use ($server) { ... }); to send pings. 2. Add a middleware that forces handshake completion within 10 seconds. 3. Set a systemd or ulimit higher than default (1024) for the service. 4. Implement a monitoring script that logs connection count and alerts on rapid growth.- Always enable heartbeat timers in production WebSocket servers.
- Validate handshake completion within a timeout.
- Monitor file descriptor usage and connection pool size.
- Assume clients will disconnect uncleanly—plan for that.
lsof -p <pid> | wc -l to count file descriptors. If growing, dump active connections via tcpdump or Ratchet's internal state to find zombie connections.sudo lsof -i :8080 | wc -lss -tn state established sport = :8080sudo kill -9 <pid> then restart with heartbeat enabled.Key takeaways
Common mistakes to avoid
3 patternsSkipping handshake validation
Not removing dead connections from rooms
Ignoring heartbeat/ping frames
$conn->send(ping) and listen for onPong.Interview Questions on This Topic
How does the WebSocket handshake work, and what headers are critical?
Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Key (16-byte random value base64-encoded), Sec-WebSocket-Version (must be 13). Server responds with 101 and Sec-WebSocket-Accept computed by concatenating the key with the magic GUID '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', then SHA-1 hashing and base64-encoding. If any header is missing or version incorrect, reject immediately.Frequently Asked Questions
20+ years shipping production PHP systems at scale. Notes here come from systems that actually shipped.
That's Advanced PHP. Mark it forged?
3 min read · try the examples if you haven't