← Back to blog
Backend Mar 27, 2026 · 4 min read

Debugging a WebSocket Memory Leak in a Laravel Octane Worker

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:

  1. 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.
  2. Growing Connection Registry: We inspected the in-memory $connections hash map. Disconnected client keys were correctly removed upon client disconnect (onClose).
  3. 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.

Laravel Octane Memory Leak Diagnostic Workflow

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:

  1. Event resolves to the global application container singleton Illuminate\Events\Dispatcher.
  2. Every call to Event::listen() appends a new anonymous closure to the global Dispatcher::$listeners array.
  3. The closure implicitly binds $connection via its lexical scope (use ($connection)).
  4. The $connection instance holds strong references to the User model, Request attributes, and database query log.
  5. On client disconnect, $listeners was 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

  1. Beware Global Singletons: Any global event dispatcher, logger array, or static registry (Class::$instances[]) will retain references indefinitely in Octane.
  2. Audit Closure Scope: Anonymous closures automatically bind $this and any variables in use (...). Avoid registering closures in long-lived singletons.
  3. Profile Real Memory: Use memory_get_usage() tracking and memory snapshot tools like Blackfire or Xdebug memory profiling when testing Octane worker lifecycle loops.