Home›PHP›WebSockets in PHP — Stop Zombie Connections in Ratchet
Advanced
3 min · March 06, 2026
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..
N
NarenFounder & Principal Engineer
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
✦ Definition~90s read
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.
★
Imagine you're waiting for a pizza delivery.
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.
Plain-English First
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.
⚙ Browser compatibility
Latest versions — ✓ supported
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.
WebSocketServer.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<?php
namespace io\thecodeforge\websocket;
useRatchet\MessageComponentInterface;
useRatchet\ConnectionInterface;
classZombieKillerimplementsMessageComponentInterface
{
publicfunctiononOpen(ConnectionInterface $conn)
{
// Validate handshake manually if needed// Ratchet does basic validation, but we can check origin, etc.echo"New connection: {$conn->resourceId}\n";
}
publicfunctiononMessage(ConnectionInterface $from, $msg)
{
// Handle incoming messageecho"Received: $msg\n";
}
publicfunctiononClose(ConnectionInterface $conn)
{
// Clean up resourcesecho"Connection {$conn->resourceId} closed\n";
}
publicfunctiononError(ConnectionInterface $conn, \Exception $e)
{
// Log and closeecho"Error on {$conn->resourceId}: {$e->getMessage()}\n";
$conn->close();
}
}
Output
New connection: 1
New connection: 2
Received: hello
Connection 1 closed
⚠ Production Pitfall:
Always set a component-level timeout. If a connection opens but never sends a handshake upgrade request, Ratchet will hang indefinitely. Use a timer in the IoLoop to drop stalled connections after 5 seconds.
📊 Production Insight
Zombie connections don't show up in application logs.
You'll only notice when netstat -an shows thousands of CLOSE_WAIT sockets.
Set connection-level timeouts and implement a periodic sweep to close stale streams.
🎯 Key Takeaway
Validate the WebSocket handshake on both ends.
A missing or malformed upgrade request is the #1 cause of zombie connections.
Close any connection that doesn't complete handshake within 10 seconds.
thecodeforge.io
Websockets Php
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.
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
📊 Production Insight
An invalid handshake can leave the server in an inconsistent state.
If you skip origin validation, an external site can open WebSocket connections to your server (CSWSH attack).
Always validate Origin header and enforce HTTPS in production.
🎯 Key Takeaway
Reject handshakes that don't match expected spec.
A valid upgrade is the only gateway to a clean connection.
Without it, you're building on sand.
Managing Connections and Rooms
In production, you need to group connections into rooms or channels for broadcasting. Ratchet provides a Topicabstraction 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.
Connections are grouped by room; broadcast sends to all members.
💡Memory Management Tip:
Use weak references if possible, or explicitly null out entries on close. SplObjectStorage is fine, but for high-volume rooms, consider using an array of resource IDs and a separate object store.
📊 Production Insight
Room managers are a classic memory leak source.
If you forget to remove a dead connection, the loop never stops trying to send to it—causing silent errors and accumulating memory.
Always log the count of active connections per room to catch leaks early.
🎯 Key Takeaway
Remove connections from all data structures on close.
A stray reference in a room table is a memory leak waiting to happen.
Audit your connection container size periodically.
thecodeforge.io
Websockets Php
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
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.
Memory usage after heartbeat: stable at 45MB for 500 connections (vs 120MB without).
⚠ Production Trap:
PHP's garbage collector won't clean references held in long-lived arrays. Use a dedicated connection repository with WeakReference or manual deletion. Never unset() inside a foreach — it corrupts the internal pointer.
🎯 Key Takeaway
Always pair connection creation with a guaranteed cleanup path. A missed close event is a memory leak in disguise.
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.
Set 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.
🎯 Key Takeaway
Slow clients should never degrade fast ones. Use write queues and non-blocking I/O to decouple broadcast from delivery.
thecodeforge.io
Websockets Php
● Production incidentPOST-MORTEMseverity: high
The Zombie Overflow
Symptom
New WebSocket connections fail with 'Connection refused' or timeout. Server netstat shows many sockets in CLOSE_WAIT state. PHP logs show 'Too many open files' error.
Assumption
The team assumed Ratchet's default settings handle disconnections gracefully. They believed client-side cleanup was sufficient.
Root cause
Clients disconnected without sending proper close frames (e.g., mobile app killed, browser tab closed). The server never received the close event and kept the connection open. Ratchet's default heartbeat interval was 0 (disabled), so no periodic ping/pong detected dead streams. Over hours, connections accumulated until the file descriptor limit was hit.
Fix
1. Enable Ratchet's heartbeat: $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.
Key lesson
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.
Production debug guideSymptom → Action guide for common Ratchet failures4 entries
Symptom · 01
WebSocket connection opens but closes immediately
→
Fix
Check server logs for handshake rejection (403). Verify Origin header matches allowed list. Ensure client sends correct Sec-WebSocket-Version (13).
Symptom · 02
Server memory grows over time, no error in logs
→
Fix
Run 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.
Symptom · 03
Messages are lost or delivered to wrong room
→
Fix
Check that onClose properly removes connection from room manager. Add logging in broadcast to verify connection count per room.
Symptom · 04
High CPU usage on Ratchet server
→
Fix
Profile with Xdebug or Blackfire. Common cause: infinite loop in onMessage due to blocking operations. Ensure all callbacks are non-blocking.
★ WebSocket Ratchet Quick DebugQuick commands and checks to diagnose zombie connections and handshake failures.
Zombie sockets accumulating−
Immediate action
Check file descriptor count and active connections
Commands
sudo lsof -i :8080 | wc -l
ss -tn state established sport = :8080
Fix now
Kill stale connections: sudo kill -9 <pid> then restart with heartbeat enabled.
Handshake fails (101 not received)+
Immediate action
Inspect the upgrade request with tcpdump
Commands
tcpdump -i any port 8080 -X
Check if key and version are present
Fix now
Add middleware to log full request headers, ensure version=13.
Connection drops after idle+
Immediate action
Verify heartbeat interval on both sides
Commands
Check Ratchet timer interval in code
Check client's WebSocket ping/pong implementation
Fix now
Set server ping interval to 30s, client pong response timeout to 10s.
Approach
Latency
Server Resource Usage
Scalability
Short Polling
High (poll interval)
High (many requests)
Low
Long Polling
Medium
Medium (held requests)
Medium
WebSockets (Ratchet)
Very Low (~ms per message)
Low (single persistent connection)
High (with proper scaling)
⚙ Quick Reference
5 commands from this guide
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
Key takeaways
1
WebSocket connections are not free—always assume they can die silently.
2
Validate handshake fully
key, version, origin.
3
Implement heartbeat to detect and close zombie connections.
4
Manage room data structures carefully—remove connections on close.
5
Monitor FD count and connection pool as part of production health checks.
Common mistakes to avoid
3 patterns
×
Skipping handshake validation
Symptom
Connections open but never exchange data; server shows CLOSE_WAIT sockets; memory grows over time.
Fix
Ensure Ratchet's HTTP middleware validates key, version, and origin. Add a timeout to abort incomplete handshakes.
×
Not removing dead connections from rooms
Symptom
Broadcast loops over stale connections; PHP memory limit exhausted; connections pile up in SplObjectStorage.
Fix
In the onClose callback, iterate over all room data structures and unset the connection using its resource ID.
×
Ignoring heartbeat/ping frames
Symptom
Connections appear open but are actually dead (e.g., client laptop sleeps). Server holds stale file descriptors until TCP timeout (hours).
Fix
Implement a periodic ping (every 30 seconds) and close the connection if no pong is received within 10 seconds. Use Ratchet's $conn->send(ping) and listen for onPong.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
How does the WebSocket handshake work, and what headers are critical?
Q02SENIOR
What causes zombie WebSocket connections and how do you prevent them?
Q03SENIOR
How would you scale a Ratchet WebSocket server horizontally?
Q01 of 03SENIOR
How does the WebSocket handshake work, and what headers are critical?
ANSWER
The handshake starts with an HTTP upgrade request. Critical headers: 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.
Q02 of 03SENIOR
What causes zombie WebSocket connections and how do you prevent them?
ANSWER
Zombies occur when the handshake is incomplete but the TCP connection stays open, or when the client disconnects uncleanly (e.g., browser tab closed without close frame). Prevention: validate the full handshake before accepting, implement connection timeouts for incomplete upgrades, send periodic pings, and aggressively close stale connections on missing pong responses.
Q03 of 03SENIOR
How would you scale a Ratchet WebSocket server horizontally?
ANSWER
Ratchet itself is single-process; to scale, run multiple instances behind a load balancer. Use a shared message broker (Redis Pub/Sub or RabbitMQ) to broadcast messages across instances. Each Ratchet server subscribes to a common channel and publishes messages locally. For sticky sessions, use a consistent-hash-based load balancer or manage session affinity with a shared database/Redis for room membership.
01
How does the WebSocket handshake work, and what headers are critical?
SENIOR
02
What causes zombie WebSocket connections and how do you prevent them?
SENIOR
03
How would you scale a Ratchet WebSocket server horizontally?
SENIOR
FAQ · 3 QUESTIONS
Frequently Asked Questions
01
What is WebSockets in PHP in simple terms?
WebSockets let your PHP server push data to a browser or app in real time, without the client asking repeatedly. It's a persistent, two-way channel over a single TCP connection.
Was this helpful?
02
Why are zombie connections a problem in Ratchet?
Because Ratchet runs in a single process, every zombie eats a file descriptor (default limit 1024). Once exhausted, no new connections can be accepted. Memory also leaks as connections hold references to other objects.
Was this helpful?
03
How do I implement a heartbeat in Ratchet?
Add a periodic timer to your event loop that sends a ping frame to all connections. Use Ratchet's ConnectionInterface::send() with send(ping). Listen for onPong and set a time limit—if no pong, call close().