Laravel Reverb in Production — Horizontal Scaling, Presence Channels, and Load Balancing

Production playbook for Laravel Reverb: Redis pub/sub scaling, presence channels, sticky sessions, Supervisor, ulimits, and Pulse monitoring.

Steven Richardson
Steven Richardson
· 19 min read

A team ships a Reverb-powered live chat to production on a two-node cluster behind an AWS ALB. The demo on the dev box was flawless. In staging, with two browser windows open, connections start dropping every thirty seconds and the Echo client reconnects in a loop. The cause is split between three knobs nobody documents together: missing sticky sessions, an unset REVERB_SCALING_ENABLED, and a plain HTTP health check that fails every Reverb pod within a minute of going live.

This guide walks the full production playbook for Laravel Reverb: how to fan broadcasts across nodes with Redis pub/sub, define presence channels with member tracking, configure sticky sessions on the major load balancers, supervise the long-lived process, run a WebSocket-aware health check, raise file descriptor limits, monitor connection counts with Pulse, and soak test the cluster with k6. By the end you will have a Reverb deployment that survives a node restart, scales horizontally, and shows up correctly in monitoring.

Provision Redis for Reverb pub/sub#

Reverb's horizontal scaling layer is a Redis pub/sub bridge. Each node subscribes to a channel, every broadcast goes through Redis, and every other node forwards the message to its locally connected clients. Before any of the other steps work, you need a Redis instance every Reverb host can reach with sub-millisecond latency, and a dedicated database index so Reverb's pub/sub traffic does not collide with your cache or queue keys.

Spin up Redis 7.x (Reverb supports the same versions as the Laravel queue driver). On AWS this means an ElastiCache replication group, single-shard, multi-AZ, with cluster mode disabled — pub/sub does not work across cluster shards. On a self-hosted box, Sentinel is overkill; a primary with a hot standby is sufficient for Reverb's traffic profile. Put the Redis instance inside the same VPC and security group as your Reverb hosts so the round trip stays under 1ms.

Add these to the .env on every Reverb node:

REVERB_SCALING_ENABLED=true

REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080

# Pub/sub uses the default Redis connection unless you split it out.
REDIS_HOST=reverb-redis.internal
REDIS_PORT=6379
REDIS_DB=2

Picking a dedicated REDIS_DB index (2 in this example, separate from your default 0 for cache and 1 for queues) keeps Reverb's pub/sub traffic isolated. If you want a fully separate connection — common when Redis lives in a different network from your cache — register one in config/database.php and add BROADCAST_REDIS_CONNECTION to point Reverb at it.

Verify the bridge with a quick subscriber on one host while you fire a broadcast from another:

redis-cli -h reverb-redis.internal -n 2 PSUBSCRIBE "reverb*"

If REVERB_SCALING_ENABLED=true is set on the publisher and you trigger any broadcast event, you should see messages flow on the subscriber side. No messages means Reverb is still running in single-node mode — re-check the env var and restart the process.

Enable horizontal scaling on every Reverb node#

REVERB_SCALING_ENABLED=true is the single switch that flips Reverb from in-memory broadcasting to Redis-backed broadcasting. With it off, every connection a client opens is bound to whichever node the load balancer routes it to, and a broadcast fired on Node A never reaches a subscriber connected to Node B. With it on, every node publishes to the shared Redis channel on broadcast and subscribes for local re-broadcast. This is the production-default; never run a multi-node Reverb cluster without it.

Set the flag on every node and rebuild your config cache. If you are using Docker, bake it into the image's environment or pass it through your orchestrator's secret manager. If you are deploying with Forge or Cloud, set it on the environment screen so the value survives php artisan config:cache. The misconfiguration that causes the most production incidents in my experience is one node missing the flag because someone hand-edited an .env after a deploy.

Restart the Reverb process to pick up the change. From a Supervisor-managed host:

sudo supervisorctl restart reverb:*

Run php artisan reverb:restart immediately after if you have other code paths queuing broadcasts; the artisan command signals every running Reverb worker to gracefully cycle, picking up the new .env without dropping in-flight messages. Reverb's broadcasting layer reads broadcast payloads off a queue worker, so if you are following the same patterns from scaling Laravel queues in production, the workers themselves cycle on their normal --max-jobs rotation and pick up new code automatically.

A common newcomer assumption: enabling scaling on Reverb does not mean every node terminates every client connection. Each client is still pinned to a single node through sticky sessions (covered shortly). The scaling layer only fans server-to-server broadcast messages so any client, anywhere in the cluster, receives the event. This distinction matters because it affects how you reason about presence channels and member counts.

Define a presence channel and broadcast member events#

Presence channels are the killer feature for collaborative UI: typing indicators, live cursors, online member lists, and "Steven is editing" badges all use the same underlying mechanism. They are private channels with an extra contract: every client that subscribes is required to identify itself, the server tracks the live membership, and member.added and member.removed events fire automatically as people join and leave.

Define the channel authorisation in routes/channels.php. The return value is what becomes the member's identity on the channel — typically an array shaped with id, name, and whatever else the UI needs:

use App\Models\Room;
use App\Models\User;

Broadcast::channel('presence-room.{room}', function (User $user, Room $room) {
    if (! $room->users()->whereKey($user->id)->exists()) {
        return null;
    }

    return [
        'id' => $user->id,
        'name' => $user->name,
        'avatar_url' => $user->avatar_url,
    ];
});

Return null to deny, return an array to authorise. The shape of the array is what every other subscriber receives in their here, joining, and leaving callbacks. On the frontend, Echo's join() API gives you the lifecycle hooks:

window.Echo.join(`presence-room.${roomId}`)
    .here((users) => {
        // Initial snapshot of who is in the room.
        renderMemberList(users);
    })
    .joining((user) => {
        showToast(`${user.name} joined`);
    })
    .leaving((user) => {
        showToast(`${user.name} left`);
    })
    .listenForWhisper('typing', (event) => {
        showTypingIndicator(event.userId);
    });

Whispers (the .whisper() / .listenForWhisper() pair) are client-to-client events that never touch the server side of your application; they hop straight through Reverb to every other subscriber on the channel. Perfect for typing indicators because they generate zero queue jobs and zero database writes — important when a busy presence channel can have hundreds of people typing simultaneously.

For server-side broadcasts triggered by your own events, implement ShouldBroadcastNow on the event class so the payload skips the queue and hits Reverb immediately. The standard ShouldBroadcast is fine for less latency-sensitive events but adds the queue-worker hop you saw documented in the Reverb real-time notifications walkthrough. Save ShouldBroadcastNow for chat messages and presence whispers; use ShouldBroadcast for everything else.

One gotcha worth flagging early: presence channel membership is tracked per-connection, not per-user. If the same user opens five browser tabs they are counted as five members. Echo deduplicates the events client-side based on the array you return from authorisation, but server-side counters need to handle the duplication explicitly. The cleanest fix is to key your "online users" list by user.id rather than connection ID before rendering.

Supervise reverb:start with Supervisor#

php artisan reverb:start is a long-lived foreground process. Production deployments need to keep that process alive across crashes, OS restarts, and code deploys. The standard answer in the Laravel world is Supervisor (or systemd on hosts without Supervisor). The mistake to avoid is daemonising it by hand with nohup & — when the process crashes, nothing restarts it, and php artisan reverb:restart cannot signal a manually backgrounded job.

Drop a program file at /etc/supervisor/conf.d/reverb.conf:

[program:reverb]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan reverb:start --host=0.0.0.0 --port=8080
autostart=true
autorestart=true
user=deploy
numprocs=1
redirect_stderr=true
stdout_logfile=/var/log/supervisor/reverb.log
stopwaitsecs=15
stopsignal=SIGTERM
killasgroup=true
stopasgroup=true

Two flags here matter more than the rest. stopsignal=SIGTERM lets Reverb finish its current event loop and broadcast a clean disconnect to subscribers; SIGKILL would leave half-written frames on the wire and trigger Echo reconnects across the cluster. stopwaitsecs=15 gives Reverb up to 15 seconds to drain — longer than the default 10, which is occasionally tight when a node is holding several thousand connections.

Apply the config:

sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start reverb:*

reverb:restart is the artisan command that signals the running process to cycle gracefully without Supervisor intervention. Trigger it from your deploy hook after composer install and php artisan config:cache. The same pattern works for queue workers — there is a good walkthrough of the equivalent rotation knobs over at Laravel queue worker max jobs and max time that maps cleanly to how reverb:restart is used in a deploy script.

If you are running on a host without Supervisor (some lightweight VPS images and minimal Docker bases), the systemd equivalent is functionally identical:

[Unit]
Description=Laravel Reverb
After=network.target

[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/html
ExecStart=/usr/bin/php artisan reverb:start --host=0.0.0.0 --port=8080
Restart=always
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=15
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target

The LimitNOFILE line bumps the file descriptor limit for the Reverb service specifically — useful preview of the host-wide tuning covered shortly. Either approach is fine; Supervisor wins when you are already managing Laravel queues with it, systemd wins when you are not.

Configure sticky sessions on the load balancer#

WebSocket connections are stateful: the upgrade handshake from HTTP to WS happens once, and from that moment the client and the server are tied together until disconnection. Behind a load balancer with round-robin routing, every reconnect rolls a fresh node, and presence channel state for that connection has to rebuild from scratch. Worse, without sticky sessions, the upgrade frame and the first protocol frame can land on different nodes, breaking the handshake outright. Sticky sessions are mandatory.

On AWS ALB, sticky sessions live on the target group. The simplest workable shape is application-controlled cookies with a long duration:

TargetGroup:
  Type: AWS::ElasticLoadBalancingV2::TargetGroup
  Properties:
    Name: reverb-tg
    Protocol: HTTP
    Port: 8080
    VpcId: !Ref VpcId
    HealthCheckPath: /up
    HealthCheckProtocol: HTTP
    TargetGroupAttributes:
      - Key: stickiness.enabled
        Value: 'true'
      - Key: stickiness.type
        Value: lb_cookie
      - Key: stickiness.lb_cookie.duration_seconds
        Value: '86400'

The lb_cookie type tells ALB to issue its own AWSALB cookie and re-route every request from the same client back to the same node. Duration of one day is fine for most apps; longer durations are wasteful because clients will reconnect and re-stick anyway. The HealthCheckPath value is intentionally /up here — that path is covered in the next step.

For NGINX in front of Reverb, the equivalent shape uses the ip_hash directive on the upstream block, plus the WebSocket upgrade headers:

upstream reverb_backend {
    ip_hash;
    server reverb-1.internal:8080;
    server reverb-2.internal:8080;
}

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 443 ssl http2;
    server_name reverb.example.com;

    ssl_certificate     /etc/ssl/reverb.crt;
    ssl_certificate_key /etc/ssl/reverb.key;

    location / {
        proxy_pass http://reverb_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

ip_hash pins each client IP to a node; the Upgrade and Connection headers tell NGINX to honour the WebSocket protocol upgrade. The 3600-second read and send timeouts are critical — without them NGINX will tear down idle WebSocket connections after the default 60 seconds, and your clients will see seemingly random disconnects after a minute of inactivity.

Caddy is much terser. The reverse_proxy directive handles WebSocket upgrades natively, and the lb_policy ip_hash line provides stickiness:

reverb.example.com {
    reverse_proxy reverb-1.internal:8080 reverb-2.internal:8080 {
        lb_policy ip_hash
        transport http {
            read_timeout 1h
            write_timeout 1h
        }
    }
}

Cloudflare Spectrum (which terminates wss on Cloudflare's edge and forwards to your origin) uses session affinity at the application level — flip "Session Affinity" on for the Spectrum app pointing at your Reverb hosts. The cookie name is __cflb and the default duration is acceptable.

Add a WebSocket-aware health check#

The single most common reason a new Reverb cluster reports "unhealthy" on day one is a plain HTTP GET health check pointed at the WebSocket port. Reverb does respond to a regular HTTP request on /, but it returns a 426 ("Upgrade Required") because it expects a WebSocket handshake — and most load balancers consider 426 a failure. The fix is to either give the health checker a real upgrade request, or expose a sidecar HTTP endpoint that proxies a Reverb status read.

Laravel 12+ ships a /up route by default that returns 200 OK. Run Reverb behind a tiny PHP-FPM or Octane sidecar on a separate port (commonly 8081) and point the load balancer's health check at that — /up lives inside the regular HTTP application so it stays healthy even when Reverb itself is mid-restart. The shape is identical to the readiness probes used in Laravel health checks for Kubernetes pods, so if you are already running k8s probes you have the pattern.

For platforms that support upgrade-aware checks, configure them to use the WebSocket handshake directly. AWS ALB target groups accept a HealthCheckPath of /app/{reverb_app_id} with the matcher set to look for 101 Switching Protocols. The simpler approach in practice is a tiny shell script wrapped in a small HTTP endpoint:

// routes/web.php

Route::get('/up/reverb', function () {
    $context = stream_context_create([
        'http' => [
            'method' => 'GET',
            'header' => "Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n",
            'timeout' => 2,
        ],
    ]);

    $headers = @get_headers('http://127.0.0.1:8080/app/' . config('reverb.apps.apps.0.app_id'), context: $context);

    if ($headers && str_contains($headers[0], '101')) {
        return response()->noContent(204);
    }

    return response()->json(['status' => 'unhealthy'], 503);
});

The endpoint sends a real upgrade request, looks for a 101 response (the WebSocket handshake-complete code), and returns 204 to the load balancer. Set the load balancer's health check to look for 204; flip to 503 when Reverb is down. Threshold the check at 3 consecutive failures over 30 seconds so a normal reverb:restart does not flap the pod.

The Reverb application ID in config('reverb.apps.apps.0.app_id') comes from config/reverb.php. If you are running multiple Reverb apps on the same node (rare, but supported) you will need a check per app or a script that walks the list.

Tune ulimits for high connection counts#

Every WebSocket connection is a file descriptor. The default nofile limit on most Linux distributions is 1024, which means a single Reverb process tops out at about 1000 simultaneous connections before accept() starts failing with EMFILE. Raising this to something sensible — 65,535 is the conventional ceiling — is the difference between hitting a wall at a few hundred users and being able to absorb the traffic this guide is designed to handle.

Set the limit in /etc/security/limits.conf:

deploy soft nofile 65535
deploy hard nofile 65535

Where deploy is the user Supervisor (or systemd) runs the Reverb process as. Sessions started after this change pick up the new limits; existing sessions need to log out and back in. Confirm with:

sudo -u deploy ulimit -n

For Supervisor-managed processes, also set minfds in /etc/supervisor/supervisord.conf:

[supervisord]
minfds=65535
minprocs=200

Restart Supervisor with sudo systemctl restart supervisor so it inherits the new limit before spawning Reverb. For systemd-managed Reverb (the unit shown earlier) the LimitNOFILE=65535 directive does the same job inline.

You also want to lift the kernel-level cap on TCP connections per port. Two settings in /etc/sysctl.conf:

net.core.somaxconn = 4096
net.ipv4.tcp_max_syn_backlog = 4096

Apply with sudo sysctl -p. Without these, you will see "connection reset by peer" errors under burst load even when the per-process file descriptor limit is fine.

How many connections can a single tuned node hold? The honest answer is "it depends on your event volume and payload size", but as a rough starting figure: a 4-vCPU, 8GB-RAM Reverb host with these tunings sustains roughly 8,000–12,000 idle presence channel connections with a steady drip of small broadcasts, and roughly 3,000–5,000 connections under heavy whisper traffic. Scale by adding nodes once a single host exceeds 70% sustained CPU.

Track connections with a custom Pulse recorder#

Reverb does not ship a built-in dashboard for live connection counts. The simplest production-grade answer is a custom Laravel Pulse recorder that polls the Reverb stats endpoint every few seconds and surfaces the number on the Pulse dashboard alongside your queue, slow query, and exception data. The general pattern is covered end-to-end in building custom recorders for Laravel Pulse; this section adapts it to Reverb specifically.

Create a ReverbConnectionsRecorder in app/Pulse/Recorders/:

<?php

namespace App\Pulse\Recorders;

use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Http;
use Laravel\Pulse\Events\SharedBeat;
use Laravel\Pulse\Facades\Pulse;

class ReverbConnectionsRecorder
{
    public string $listen = SharedBeat::class;

    public function record(SharedBeat $event): void
    {
        if ($event->time->second % 5 !== 0) {
            return;
        }

        $stats = Http::get('http://127.0.0.1:8080/up/stats')->json();

        Pulse::record(
            type: 'reverb_connections',
            key: gethostname(),
            value: (int) ($stats['connections'] ?? 0),
            timestamp: $event->time,
        )->avg()->onlyBuckets();
    }
}

Register it in config/pulse.php:

'recorders' => [
    \App\Pulse\Recorders\ReverbConnectionsRecorder::class => [
        'sample_rate' => 1,
    ],
    // ... other recorders
],

The SharedBeat event fires once per second across the Pulse worker, so the % 5 modulo throttles the recorder to every 5 seconds — frequent enough to spot a spike, infrequent enough to keep the Pulse storage table small. Recording with gethostname() as the key gives you a separate line per node when you scale to a multi-node cluster, so you can spot uneven distribution from a misconfigured load balancer at a glance.

Render the values on the Pulse dashboard by publishing a Blade card:

<x-pulse::card cols="6">
    <x-pulse::card-header
        name="Reverb Connections"
        title="Live WebSocket connections per node"
    >
        <x-slot:icon>
            <x-pulse::icons.signal />
        </x-slot:icon>
    </x-pulse::card-header>

    <x-pulse::scroll>
        @foreach ($connections as $host => $count)
            <div class="flex items-center justify-between py-1">
                <span class="font-mono text-sm">{{ $host }}</span>
                <span class="text-2xl">{{ number_format($count) }}</span>
            </div>
        @endforeach
    </x-pulse::scroll>
</x-pulse::card>

Pair this with the connection-count metric from your APM (Datadog, New Relic, AppSignal) for a second source of truth. Pulse is great for in-app visibility, but APMs alert on thresholds — set one at 70% of a node's known ceiling so you scale before users notice. The comparison in Telescope, Debugbar, and Pulse for Laravel is worth a re-read if you are not sure which observability layer to add first.

Run a soak test with k6#

A Reverb cluster has to survive sustained connection load, not just a brief burst. k6 is the go-to tool for this — Grafana's load-testing CLI speaks WebSocket natively and produces clean reports. The pattern is to open a few thousand connections, hold them, broadcast at a realistic frequency, and watch the cluster's CPU, memory, and connection-count graphs in Pulse.

A reasonable starter script lives in tests/load/reverb-soak.js:

import ws from 'k6/ws';
import { check, sleep } from 'k6';

export const options = {
    vus: 1000,
    duration: '15m',
    thresholds: {
        ws_connecting: ['p(95)<500'],
        ws_session_duration: ['p(95)>800000'],
    },
};

const REVERB_URL = 'wss://reverb.example.com/app/your-app-id';

export default function () {
    const params = { tags: { name: 'presence-room' } };

    const res = ws.connect(REVERB_URL, params, function (socket) {
        socket.on('open', () => {
            socket.send(JSON.stringify({
                event: 'pusher:subscribe',
                data: { channel: 'presence-room.1' },
            }));
        });

        socket.on('message', (data) => {
            // Echo any incoming whisper.
            const payload = JSON.parse(data);
            if (payload.event === 'client-typing') {
                // No-op; we are observing only.
            }
        });

        socket.setTimeout(() => socket.close(), 900000);
    });

    check(res, { 'status is 101': (r) => r && r.status === 101 });

    sleep(1);
}

Run it from a separate host (never the Reverb cluster itself — you want clean traffic):

k6 run --out json=results.json tests/load/reverb-soak.js

A healthy run shows ws_connecting p95 well under 500ms, zero ws_session_duration failures, and a smooth connection-count line in Pulse climbing to your target VU count and holding flat for the full 15 minutes. If you see a sawtooth pattern — count climbing, dropping, climbing — sticky sessions are misconfigured. If you see runaway CPU on one node while the others sit idle, you have an unbalanced load balancer or a missed scaling flag on the busy node.

Run the test once before you launch, again after the first week of real traffic, and quarterly thereafter. Reverb's performance characteristics drift as your app evolves — a new feature that fans out a broadcast to every presence channel member can change the ceiling by an order of magnitude.

Plan for production gotchas and the rollout#

A few operational details remain that do not fit neatly into a single configuration step, but ignoring them is how Reverb deployments end up paged at 2am. Zombie connections from clients with crashed network stacks accumulate over time — Reverb's default ping interval (30 seconds) prunes them, but if you have aggressive proxy timeouts in front of Reverb the ping can be dropped on the wire and the server keeps the slot open. Set Reverb's pulse_interval (in config/reverb.php) lower than your proxy's idle timeout to keep the heartbeat flowing.

Broadcast loops are another foot-gun. Firing a broadcast event from inside a listener that itself is triggered by a broadcast creates an infinite loop that fans out across every node, every connection, until something runs out of memory. The fix is process discipline rather than code: keep broadcast handlers thin, never let a presence event trigger another presence event server-side, and use whispers for any client-to-client communication that does not need server validation. The same pattern principles from scaling Laravel queues in production apply here — broadcasts and queue jobs both fan out, and both need a chain-of-responsibility design rather than a callback graph.

Channel name collisions show up when you scale the cluster and have not namespaced channels by tenant or environment. The fix is to prefix every channel name with an environment marker (production-presence-room.1 rather than presence-room.1) before you hit the first incident. This is easier to do up front than after the first cross-environment broadcast bug.

For the actual rollout: deploy your Reverb config to a single node first, observe Pulse for an hour, then enable scaling and roll out to the rest of the cluster. If the deployment is part of a larger Octane or FrankenPHP setup, the patterns from Laravel Octane with FrankenPHP in production cover how to layer Reverb's long-lived process onto the same hosts safely. From the queue side, the same Supervisor templates you used for Horizon described in Laravel Horizon queue monitoring in production double as a working baseline for the Reverb program file. If you are starting smaller and want to defer the Redis dependency, the database driver for Reverb is a reasonable single-node alternative until you hit the limits described in this guide.

FAQ#

Can Laravel Reverb scale horizontally across multiple servers?

Yes. Set REVERB_SCALING_ENABLED=true on every Reverb node and provision a shared Redis instance reachable from all of them. Reverb publishes broadcast messages to a Redis channel and every node subscribes, so a client connected to Node A receives broadcasts fired on Node B. Without the flag, Reverb runs in single-node mode and broadcasts only reach clients on the same instance that originated the event.

What is REVERB_SCALING_ENABLED?

REVERB_SCALING_ENABLED is the environment variable that toggles Reverb between single-node (in-memory) broadcasting and multi-node (Redis pub/sub) broadcasting. When set to true, every broadcast goes through Redis so it reaches subscribers on any node. When unset or false, Reverb keeps broadcasts within the originating process. This is the single most important flag for any production deployment with more than one Reverb instance.

How do presence channels work with Reverb?

Presence channels are private channels that track member state on the server. Each client that subscribes is authorised through a callback in routes/channels.php that returns a member descriptor array (typically the user's id and name). Reverb tracks the live membership list and fires member.added and member.removed events automatically as clients subscribe and disconnect. Echo's join() API exposes here, joining, and leaving callbacks for the lifecycle, making them ideal for online indicators, typing notifications, and live cursor features.

Do I need sticky sessions for Laravel Reverb behind a load balancer?

Yes, sticky sessions are mandatory. A WebSocket connection is stateful: the upgrade handshake completes once and the subsequent protocol frames belong to that specific TCP connection on that specific node. Without stickiness the load balancer may route the upgrade and the first frame to different nodes, breaking the handshake. On AWS ALB use lb_cookie stickiness on the target group; on NGINX use ip_hash on the upstream; on Caddy use lb_policy ip_hash; on Cloudflare Spectrum enable Session Affinity.

How do I run Laravel Reverb under Supervisor in production?

Drop a Supervisor program file at /etc/supervisor/conf.d/reverb.conf that runs php artisan reverb:start --host=0.0.0.0 --port=8080 with autostart=true, autorestart=true, stopsignal=SIGTERM, and stopwaitsecs=15. Apply with sudo supervisorctl reread && sudo supervisorctl update. Use php artisan reverb:restart from your deploy script to cycle the process gracefully; Supervisor will pick it back up automatically. Never daemonise Reverb by hand with nohup.

What health check should I use for Laravel Reverb?

Plain HTTP GET against the Reverb port returns 426 ("Upgrade Required") which most load balancers treat as a failure. Use one of two approaches: expose a /up route in your regular HTTP application (PHP-FPM or Octane sidecar) and have the load balancer probe that, or write a small endpoint that sends a real WebSocket upgrade request to Reverb and returns 204 if it sees a 101 Switching Protocols response. The first approach is simpler and works on every load balancer; the second is more accurate because it verifies the WebSocket layer is healthy, not just the HTTP host.

How many connections can a single Reverb node handle?

A 4-vCPU, 8GB-RAM host with ulimit -n raised to 65535 and net.core.somaxconn set to 4096 sustains roughly 8,000–12,000 idle presence channel connections with a low broadcast rate, and roughly 3,000–5,000 connections under heavy whisper traffic. The numbers vary with payload size, broadcast frequency, and whether you are using ShouldBroadcast (queued) or ShouldBroadcastNow (direct). Plan to scale horizontally once a node exceeds 70% sustained CPU rather than chasing a fixed ceiling.

How do I monitor Reverb connection counts in production?

Write a custom Laravel Pulse recorder that polls Reverb's internal stats endpoint every few seconds and records the connection count keyed by hostname. The recorder hooks into the SharedBeat event, throttles to every 5 seconds with a modulo check, and writes to Pulse with ->avg()->onlyBuckets() for clean time-series storage. Render the data with a Blade card on the Pulse dashboard, and add an APM alert (Datadog, New Relic, or AppSignal) at 70% of a node's known ceiling so you scale before users notice.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.