log in
consulting hosting industries the daily tools about contact

Soketi: Pusher Drop-In That's Mostly Great Until It Isn't

I self-hosted Soketi as a Pusher replacement for a Laravel app and it worked beautifully — right up until it didn't. Here's what actually breaks at scale.

Soketi is one of those tools that makes you feel very smart for about three weeks. You cut your Pusher bill, you own your infrastructure, and the Laravel broadcasting layer doesn't know the difference. Then you start scaling and you realize you've traded a boring SaaS line item for an interesting ops problem.

I've run Soketi in production for a couple of clients now — one is a real estate platform with a live listing activity feed, another is an internal ops dashboard for a print management company. Different loads, different failure modes. This post is what I've actually learned, not the happy path from the README.

What Problem This Actually Solves

Pusher is excellent. The hosted product is polished, the client libraries are everywhere, and Laravel ships with first-class Pusher support. But Pusher's pricing is per-connection and per-message, and it climbs fast if you have long-lived connections or high message volume. For a client with a couple hundred concurrent users chatting on a dashboard all day, the bill starts to sting.

Soketi is an open-source WebSocket server that speaks the Pusher protocol. That's the whole pitch. Your Laravel app still calls broadcast(), your frontend still runs Laravel Echo with the Pusher JS client, and nothing in your application code changes. You just point the config at your own server instead of api.pusherapp.com.

The appeal is obvious: flat infrastructure cost, no per-message fees, data stays on your network. For HIPAA-adjacent workloads — and I have a few of those — keeping real-time events off a third-party SaaS is also a compliance argument worth making.

The Setup (the Part That Actually Works)

Soketi installs as an npm package or runs as a Docker image. I run it in Docker behind an Nginx reverse proxy with a TLS cert from Let's Encrypt. The Nginx config proxies WebSocket upgrades to Soketi's port (6001 by default) and that's about 90% of the infrastructure story.

On the Laravel side, your .env changes are minimal:

BROADCAST_DRIVER=pusher

PUSHER_APP_ID=your-app-id
PUSHER_APP_KEY=your-app-key
PUSHER_APP_SECRET=your-app-secret
PUSHER_HOST=ws.yourserver.com
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1

The cluster value doesn't matter to Soketi — it ignores it — but the Pusher JS client complains if it's missing, so pick anything.

In config/broadcasting.php, make sure you're passing the custom host:

'pusher' => [
    'driver' => 'pusher',
    'key' => env('PUSHER_APP_KEY'),
    'secret' => env('PUSHER_APP_SECRET'),
    'app_id' => env('PUSHER_APP_ID'),
    'options' => [
        'host' => env('PUSHER_HOST'),
        'port' => env('PUSHER_PORT', 443),
        'scheme' => env('PUSHER_SCHEME', 'https'),
        'encrypted' => true,
        'useTLS' => true,
    ],
    'client_options' => [],
],

Frontend Echo config:

window.Echo = new Echo({
    broadcaster: 'pusher',
    key: import.meta.env.VITE_PUSHER_APP_KEY,
    wsHost: import.meta.env.VITE_PUSHER_HOST,
    wsPort: 443,
    wssPort: 443,
    forceTLS: true,
    enabledTransports: ['ws', 'wss'],
    disableStats: true,
});

That disableStats: true matters. Without it the Pusher JS client tries to phone home to Pusher's stats endpoint and you'll see failed requests in the console. Harmless, but annoying and slightly wrong for a compliance conversation.

Soketi config lives in a soketi.json file or environment variables. The minimal version:

{
    "debug": false,
    "port": 6001,
    "appManager.driver": "array",
    "appManager.array.apps": [
        {
            "id": "your-app-id",
            "key": "your-app-key",
            "secret": "your-app-secret",
            "maxConnections": 1000,
            "enableClientMessages": false,
            "enabled": true,
            "webhooks": []
        }
    ]
}

This gets you running in an afternoon. The real-time events flow, presence channels work, private channel auth works. It's genuinely a solid drop-in for modest loads.

Where It Starts to Break

Here's where I get to be the guy who's already driven into the ditch so you don't have to.

Soketi is single-process by design. Node.js, single event loop. This is fine until it isn't. The print management client has a batch job that fires roughly 400 broadcast events in about two seconds when a large job finishes processing. On a 2-core VPS, Soketi handled it fine during development and early production. Six months later with more users connected, those bursts started introducing 3-5 second delivery delays. The WebSocket connections themselves stayed alive, but messages queued behind the event loop.

The fix that actually worked was moving heavy broadcast operations off the main request cycle and into queued jobs — which you should be doing anyway, but it became non-optional:

class JobProcessedEvent implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function broadcastOn(): array
    {
        return [new PrivateChannel('jobs.' . $this->job->id)];
    }

    public function broadcastQueue(): string
    {
        // Dedicated queue so broadcast jobs don't compete
        // with heavier processing work
        return 'broadcasting';
    }
}

Spread the broadcasts out with a delay if you're firing hundreds at once:

// Instead of broadcasting all at once in a loop
foreach ($results as $index => $result) {
    broadcast(new ItemProcessed($result))
        ->delay(now()->addMilliseconds($index * 25));
}

That 25ms stagger sounds silly but it completely eliminated the backlog spikes.

Horizontal scaling requires Redis. Out of the box, Soketi's pub/sub is in-memory. One server, one process, all connections live there. If you put two Soketi nodes behind a load balancer without configuring the Redis adapter, a broadcast sent to Node A won't reach clients connected to Node B. This bit me on the real estate client when I added a second node thinking I was being clever.

The fix is straightforward but you have to know to do it:

{
    "adapter.driver": "redis",
    "adapter.redis.prefix": "soketi",
    "adapter.redis.requestsTimeout": 5000
}

And point it at your Redis instance. Once that's in place, multi-node setups work. But now you have a Redis dependency to keep alive, which changes your failure surface.

Presence channel member counts drift. This is the one that's hardest to explain to a client. Presence channels track who's online. Under normal conditions Soketi handles join/leave events correctly. Under abnormal conditions — a node restart, a network blip, a client that disconnects uncleanly — the member list can get stale. Pusher's hosted service handles this gracefully because they've had years to tune it. Soketi's implementation is good but not bulletproof. For the real estate app where "who's viewing this listing" is a visible feature, I added a periodic server-side reconciliation job that hits the Soketi HTTP API and cleans up ghost members.

The HTTP API for server-side queries is underspecified. Pusher's API lets you query channel occupancy, trigger events server-side, etc. Soketi implements most of this but the behavior on edge cases differs. I had a monitoring script that polled /apps/{id}/channels to check connection counts. It works, but the response format for empty channels is inconsistent enough that I had to add null coalescing in a few places that felt like they shouldn't need it.

Memory growth on long-running instances. I have one Soketi instance that I restart weekly as a matter of habit now. Node.js memory management under sustained WebSocket load isn't a disaster but it's not perfect either. Watch your RSS over time. Set up a cron or a process supervisor restart policy.

When I'd Reach for Soketi

I'd use Soketi when:

  • The Pusher bill is genuinely a problem (not just a theoretical one — run the numbers first)
  • I control the infrastructure and have monitoring in place
  • Concurrent connections are in the hundreds, not tens of thousands
  • I need data residency or have compliance reasons to avoid third-party event delivery
  • The team (or just me) is comfortable owning a Node process in production

I'd stick with managed Pusher when:

  • It's an early-stage client project and ops overhead matters more than cost
  • Concurrent connections are unpredictable and could spike hard
  • The real-time feature is core to the product and downtime is genuinely expensive
  • I don't want to be on call for a WebSocket server at 2am

There's a version of this conversation where I'd look at Reverb instead — Laravel's own first-party WebSocket server that shipped in 2024. It's written in PHP, which means one fewer runtime on the server, and the Laravel team is actively maintaining it. For new projects I'm now starting with Reverb rather than Soketi. But if you have an existing Soketi or Pusher setup, there's no urgent reason to migrate.

The Closing Take

Soketi works. It's not vaporware and it's not a toy. But "it speaks the Pusher protocol" is only the beginning of the story — the ops details around memory, horizontal scaling, and presence channel consistency are where you earn your savings. If you go in knowing those are the rough edges, you'll be fine. If you deploy it like a managed service and walk away, you'll have an interesting incident sometime around month four.

Need help shipping something like this? Get in touch.