Debugging a WebSocket Memory Leak in a Laravel Octane Worker
The Symptom: Steady Memory Growth in Long-Running Octane Workers
In traditional PHP-FPM execution, PHP's request lifecycle is inherently stateless. At the end of every HTTP request, the PHP process tears down all allocated memory (RSHUTDOWN), releasing variables, closures, and database connection handles back to the operating system.
When running Laravel Octane with Swoole or FrankenPHP, worker processes remain booted in RAM across hundreds of thousands of HTTP or WebSocket requests. While this delivers sub-millisecond execution speeds, any lingering object reference or static array append creates a memory leak.
In our production real-time tracking application, an Octane worker handling active WebSocket connections exhibited a slow, relentless memory climb:
00:00 - Worker Boot: 45 MB RAM
02:00 - 1,500 Clients: 140 MB RAM
04:00 - Clients Dropped: 310 MB RAM (Memory not reclaimed!)
06:00 - OOM Killed: 512 MB RAM (Linux Kernel SIGKILL)
Restarting worker processes masked the issue temporarily, but as traffic scaled, worker processes were killed by the OS Out-Of-Memory (OOM) killer multiple times per day.
The Investigation: Eliminating the Usual Suspects
When debugging memory leaks in WebSocket applications, suspicion initially falls on socket management:
- Unclosed Socket File Descriptors: We ran
lsof -p <octane_worker_pid>to audit open TCP sockets. Operating system file descriptors matched active client counts perfectly. - Growing Connection Registry: We inspected the in-memory
$connectionshash map. Disconnected client keys were correctly removed upon client disconnect (onClose). - Garbage Collection Cycles: Manually calling
gc_collect_cycles()in the worker loop reclaimed less than 2% of allocated memory.
Crucially, memory usage continued to climb even during zero-traffic windows after all WebSocket clients had disconnected.

Root Cause Analysis: The Global Event Dispatcher Closure Leak
The root cause was located in how WebSocket channel subscriptions registered event listeners.
When a client subscribed to a real-time channel, the connection handler registered a closure-based event listener against Laravel's Event facade:
// ❌ LEAKY CODE: Executed inside Octane worker on client connection
public function onConnect(WebSocketConnection $connection): void
{
// Registering a closure against the global Event singleton
Event::listen(OrderUpdated::class, function (OrderUpdated $event) use ($connection) {
if ($event->orderId === $connection->orderId) {
$connection->send(json_encode($event->payload));
}
});
}
The Reference Chain Explored
In a stateless PHP-FPM application, Event::listen() appends a closure to an in-memory listener array that is destroyed when the HTTP response completes.
In Laravel Octane:
Eventresolves to the global application container singletonIlluminate\Events\Dispatcher.- Every call to
Event::listen()appends a new anonymous closure to the globalDispatcher::$listenersarray. - The closure implicitly binds
$connectionvia its lexical scope (use ($connection)). - The
$connectioninstance holds strong references to theUsermodel,Requestattributes, and database query log. - On client disconnect,
$listenerswas never cleaned up, leaving thousands of dead request contexts pinned in RAM indefinitely!
graph TD
subgraph Leak Mechanism
GlobalDispatcher["Global Event Dispatcher<br/>(Persists in RAM indefinitely)"] -->|Holds Array of Closures| Closure["Closure Listener<br/>fn($event) => $this->broadcast()"]
Closure -->|Lexical Scope Binding| Connection["Dead WebSocketConnection"]
Connection -->|Model Reference| User["Authenticated User & Request Context"]
end
The Solution: Scoped Event Buses and Listener Teardown
To eliminate the memory leak, we implemented a dual-layered architectural fix:
1. Explicit Listener Teardown on Disconnect
Instead of anonymous closures, we refactored event listeners into dedicated invokable classes and explicitly unregistered them on client onClose:
namespace App\Sockets;
use App\Events\OrderUpdated;
use Illuminate\Support\Facades\Event;
class ScopedOrderListener
{
public function __construct(public string $connectionId, public string $orderId) {}
public function handle(OrderUpdated $event): void
{
if ($event->orderId === $this->orderId) {
WebSocketRegistry::send($this->connectionId, $event->payload);
}
}
}
When the client disconnects, we invoke Event::forget():
public function onClose(WebSocketConnection $connection): void
{
// Explicitly remove listener from global Dispatcher array
Event::forget(OrderUpdated::class);
WebSocketRegistry::remove($connection->id);
}
2. Isolated Per-Connection Dispatchers
For complex multi-channel subscriptions, we replaced global event registration with a localized EventDispatcher instance tied directly to the lifecycle of the WebSocket connection object:
namespace App\Sockets;
use Illuminate\Events\Dispatcher;
class LocalConnectionSession
{
public Dispatcher $localEvents;
public function __construct(public string $connectionId)
{
// Instantiating a fresh local event bus isolated from Octane container
$this->localEvents = new Dispatcher();
}
public function terminate(): void
{
// Nullifying local dispatcher frees all closures for PHP GC
unset($this->localEvents);
}
}
3. Octane State Resetter Verification
Finally, we registered custom state resetters in config/octane.php to guarantee clean state across worker iterations:
'flush' => [
App\Sockets\WebSocketRegistry::class,
],
Results and Memory Benchmark
After deploying the scoped event dispatcher refactoring, we profiled the Octane worker memory usage under a synthetic load test of 50,000 sequential client connect/disconnect cycles:
| Metric | Before Fix (Leaky Closures) | After Fix (Scoped Dispatchers) | Delta |
|---|---|---|---|
| Initial Memory | 45.2 MB | 45.2 MB | 0% |
| Memory after 50k Conns | 512.0 MB (OOM Crash) | 48.6 MB | 90% RAM Saved |
| Uncollected Objects | 50,000 Closure instances | 0 residual objects | 100% GC Cleanup |
| Worker Uptime | ~6 Hours | Indefinite (30+ Days) | Zero OOM Reaps |
Key Takeaways for Long-Running PHP Applications
- Beware Global Singletons: Any global event dispatcher, logger array, or static registry (
Class::$instances[]) will retain references indefinitely in Octane. - Audit Closure Scope: Anonymous closures automatically bind
$thisand any variables inuse (...). Avoid registering closures in long-lived singletons. - Profile Real Memory: Use
memory_get_usage()tracking and memory snapshot tools like Blackfire or Xdebug memory profiling when testing Octane worker lifecycle loops.