log in
consulting hosting industries the daily tools about contact

Laravel Reverb vs Soketi: Where the Memory Model Breaks Down

I ran both under sustained WebSocket load in production and Reverb surprised me - but not always in the way Laravel's marketing would have you believe.

I've been running WebSocket servers in production since before most people had heard of Socket.io, so I came into Laravel Reverb with a healthy amount of skepticism. After running both Reverb and Soketi under sustained connections across a couple of client projects - one a real-time dashboard for an industrial monitoring company, one a collaborative quoting tool for a print shop - I have opinions. Some of them surprised me.

The short version: Reverb is genuinely good, but its memory model has a specific failure mode that will bite you if you're running long-lived connections with large channel payloads. Soketi has its own problems, mostly around the Node.js event loop under connection churn. Neither is a "just deploy it and forget it" story.

What These Tools Actually Do

Both Reverb and Soketi are self-hosted Pusher-compatible WebSocket servers. You point your Laravel broadcasting config at them, and your existing Echo frontend and broadcast() calls just work. The value proposition is obvious: you get the ergonomics of Pusher without the per-message pricing and without sending your data to a third party.

Soketi came first. It's a Node.js process, it speaks the Pusher protocol, and for a long time it was the obvious answer if you wanted self-hosted Laravel broadcasting without writing your own WebSocket server. Reverb is Laravel's first-party answer, written in PHP using ReactPHP, and it shipped with Laravel 11.

On paper, Reverb should feel more native. Same language, same toolchain, same process model you already know. In practice, the "same language" part is both the feature and the footgun.

The Reverb Memory Model Under Sustained Connections

Reverb runs as a long-lived PHP process. This is a meaningful departure from how most PHP developers have ever run code. No request/response cycle to clean up after you. No FPM worker getting recycled after 500 requests. The process boots once and stays up.

PHP's garbage collector was designed around short-lived scripts. It works, but it was not designed with the assumption that you'd hold tens of thousands of objects in memory across hours of connection time. Under sustained load, I started seeing Reverb's memory footprint grow in a pattern that looked like this:

// A simplified version of what I was broadcasting - a large presence channel payload
Broadcast::on('presence-orders.' . $locationId)
    ->with([
        'orders'   => Order::with(['items', 'customer', 'notes'])->pending()->get(),
        'agents'   => User::with('profile')->online()->get(),
        'metadata' => $this->buildMetadata($locationId),
    ])
    ->sendNow();

That payload was not huge - maybe 40-80KB serialized - but I had presence channels updating every 15 seconds across 60 concurrent connections. Reverb holds channel membership state in memory. Each connected client's presence data lives in the server process for the duration of the connection.

After about 6 hours of normal load, Reverb was sitting at 1.1GB RSS. After 18 hours, it had climbed to 2.4GB on a box with 4GB available. The process never crashed - PHP's GC eventually reclaimed most of it - but the sawtooth pattern was dramatic enough that I added a Supervisor restart schedule as a workaround.

; /etc/supervisor/conf.d/reverb.conf
[program:reverb]
command=php /var/www/html/artisan reverb:start --host=0.0.0.0 --port=8080
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stdout_logfile=/var/log/reverb/out.log
stderr_logfile=/var/log/reverb/err.log
; Restart nightly during low-traffic window to reclaim memory
; This is a workaround, not a solution

The actual solution was trimming my presence channel payloads. Presence channels are the main culprit. Public and private channels are fine - Reverb doesn't hold per-user state for those. But presence channels store a user_info blob per connection, and if you're putting fat objects in there, you're paying for it in process memory times the number of concurrent users.

// Before: bloated presence channel auth
public function join()
{
    return [
        'id'      => $this->user->id,
        'name'    => $this->user->name,
        'avatar'  => $this->user->avatar_url,
        'role'    => $this->user->role,
        'profile' => $this->user->profile, // <-- entire Eloquent model
        'prefs'   => $this->user->preferences, // <-- JSON blob from DB
    ];
}

// After: lean presence data, fetch the rest on demand
public function join()
{
    return [
        'id'   => $this->user->id,
        'name' => $this->user->name,
    ];
}

Trimming that join payload got my 18-hour memory growth from 2.4GB down to 380MB. That's the gotcha nobody talks about in the "Reverb is native PHP, it's elegant" blog posts.

Where Soketi Breaks Down

Soketi's failure mode is different, and in some ways more annoying because it's harder to observe.

Soketi is Node.js, which means it's running on a single-threaded event loop. Under steady-state connections with moderate message volume it's completely fine. Where I've seen it fall apart is connection churn - lots of clients connecting and disconnecting rapidly. Mobile web users, browser tabs being opened and closed, anything where the connection lifecycle is short and frequent.

The symptom is not memory growth. It's latency spikes. The event loop gets saturated processing handshakes and teardowns, and messages that should deliver in under 10ms start taking 400-800ms. The process stays alive, memory looks normal, but your real-time feature is now a near-real-time feature.

I hit this with the industrial monitoring project. The client had tablet-based workstations that would lock after inactivity and reconnect when someone touched the screen. During a shift change, 40 tablets would do this in about a 90-second window. Soketi's p99 latency during those windows was ugly.

I never fully fixed it on Soketi. Horizontal scaling helps (Soketi supports Redis for state sharing across nodes), but adding more Node processes to handle PHP broadcasting felt like the wrong direction. That's part of why I moved that project to Reverb when it shipped.

# Soketi Redis adapter config - necessary if you're running multiple nodes
# in docker-compose.yml
environment:
  - SOKETI_DEFAULT_APP_ID=myapp
  - SOKETI_DEFAULT_APP_KEY=mykey
  - SOKETI_DEFAULT_APP_SECRET=mysecret
  - SOKETI_DB_REDIS_HOST=redis
  - SOKETI_DB_REDIS_PORT=6379
  - SOKETI_ADAPTER_DRIVER=redis
  - SOKETI_QUEUE_DRIVER=redis

That configuration works, but it's another moving part. And Soketi's maintenance situation has been uncertain - the project went quiet for stretches in 2023-2024. I don't want to build on a foundation that might not get a security patch when I need one.

Reverb's Scaling Story is Also Not Magic

Reverb added horizontal scaling support, also via Redis. The pattern is the same as Soketi: each Reverb process publishes and subscribes through Redis so channel state is shared across nodes. It works.

// config/reverb.php - enable Redis scaling
'scaling' => [
    'enabled' => true,
    'driver'  => 'redis',
    'redis'   => [
        'connection' => 'reverb',
    ],
],

But here's what I want you to understand about scaled Reverb: you're now running multiple long-lived PHP processes, each with its own memory footprint, each connected to Redis. If your Redis connection drops, reconnection behavior in ReactPHP is not as battle-tested as you'd get from a system that was designed for event-loop programming from day one. I haven't seen it fail catastrophically, but I've seen reconnection take longer than I'd like under Redis restarts.

For most projects I run - under 500 concurrent connections, predictable channel structure - a single Reverb process on a dedicated 2-core/2GB server is completely adequate and I don't touch scaling at all.

When I'd Reach for Reverb

  • Laravel shop, everything in PHP already, team doesn't want to manage a Node process.
  • Under 500 concurrent connections.
  • Presence channels with lean join payloads.
  • You're on Laravel 11+ and you want first-party support and updates.

When I'd Reach for Soketi (or Just Use Pusher)

  • You're already running Node infra and have people who know it.
  • You need a battle-tested option today and can't wait for Reverb to mature further.
  • Or honestly: if you're over 1000 sustained connections and your business can absorb Pusher pricing, just use Pusher. The managed service is not that expensive compared to the engineering time you'll spend tuning either self-hosted option.

I moved two projects from Soketi to Reverb over the past year and I'm glad I did. But I went in expecting a drop-in improvement and got a different set of tradeoffs instead. The memory behavior under presence channels caught me off guard because I'd mentally bracketed "PHP process" as "will get recycled" - that's 25 years of muscle memory that doesn't apply here.

Reverb is the right default for a Laravel shop in 2025. Just keep your presence channel payloads skinny and monitor process memory like you would any long-running service. It's not magic - it's PHP, running forever, holding your state. Treat it accordingly.

Need help shipping something like this? Get in touch.