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.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- 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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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
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 fwrite() 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 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 socket_set_nonblock() and socket_write() with a return check. This pattern turns a O(n) broadcast into O(1) per client.
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.Laravel Reverb: First-Party WebSocket Server
Laravel Reverb is a first-party WebSocket server introduced in Laravel 11, designed to provide real-time communication with minimal configuration. Unlike Ratchet, which requires manual event loop management, Reverb integrates seamlessly with Laravel's broadcasting system and uses Laravel Echo on the frontend. It supports scaling via Redis and can handle thousands of concurrent connections without memory leaks. To use Reverb, install it via Composer: composer require laravel/reverb. Then publish the config and start the server: php artisan reverb:start. Reverb automatically handles connection upgrades, room broadcasting, and event loop management, making it ideal for Laravel applications. Below is a basic example of broadcasting an event to a room using Reverb and Laravel Echo.
FrankenPHP Server Push and Server-Sent Events
FrankenPHP is a modern PHP application server that supports Server Push and Server-Sent Events (SSE) natively, offering an alternative to WebSockets for real-time updates. Unlike WebSockets, SSE is unidirectional (server to client) and works over standard HTTP, making it simpler to implement and more firewall-friendly. FrankenPHP uses a Go-based worker to handle concurrent requests efficiently. To use SSE, create a PHP script that streams events using header('Content-Type: text/event-stream') and `echo "data: ...
";`. FrankenPHP automatically handles connection persistence and can push updates without blocking. Below is an example of an SSE endpoint that sends a timestamp every second.
Ratchet vs ReactPHP vs Reverb
Choosing the right WebSocket library depends on your project's needs. Ratchet is a mature PHP WebSocket library that provides a simple API for building real-time applications. It runs on top of ReactPHP's event loop but requires manual memory management to avoid zombie connections. ReactPHP is a low-level event-driven framework that can be used to build custom WebSocket servers, offering maximum flexibility but requiring more boilerplate. Laravel Reverb is a high-level, opinionated solution for Laravel apps, abstracting away the event loop and providing seamless integration with Laravel's broadcasting system. Below is a comparison of key features: Ratchet is best for standalone PHP WebSocket apps, ReactPHP for custom event-driven architectures, and Reverb for Laravel ecosystems. Memory management is critical in Ratchet and ReactPHP; Reverb handles it automatically.
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.| File | Command / Code | Purpose |
|---|---|---|
| WebSocketServer.php | namespace io\thecodeforge\websocket; | What is WebSockets in PHP? |
| HandshakeMiddleware.php | namespace io\thecodeforge\websocket; | Handshake Validation & Upgrade |
| RoomManager.php | namespace io\thecodeforge\websocket; | Managing Connections and Rooms |
| WebSocketServer.php | class Connection { | Why Your First WebSocket Server Will Leak Memory (and How to |
| RoomBroadcaster.php | class Room { | Broadcast to Rooms Without Blocking Your Event Loop |
| broadcast-event.php | use App\Events\MessageSent; | Laravel Reverb |
| sse.php | header('Content-Type: text/event-stream'); | FrankenPHP Server Push and Server-Sent Events |
| ratchet-server.php | use Ratchet\Server\IoServer; | Ratchet vs ReactPHP vs Reverb |
Key takeaways
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