Laravel Reverb at Scale: Broadcasting Architecture, Queue Pressure, and Channel Authorization
#laravel #reverb #websockets #broadcasting #queues

Laravel Reverb at Scale: Broadcasting Architecture, Queue Pressure, and Channel Authorization

4 min read Mohamed Said Mohamed Said

Why the Default Reverb Setup Breaks Under Load

Laravel Reverb ships with sensible defaults that work perfectly for demos and small apps. The moment you push beyond a few hundred concurrent connections or start broadcasting from high-frequency jobs, you hit a predictable set of problems: queue workers become the bottleneck, channel authorization adds latency on every subscribe, and a single Reverb process becomes a single point of failure.

This article focuses on three concrete production concerns: relieving queue pressure from broadcast events, hardening channel authorization, and running Reverb horizontally.


Decoupling Broadcast Events from the Request Cycle

Every ShouldBroadcast event dispatched inside a request is serialized and pushed onto a queue. If your broadcast queue shares workers with your default queue, a spike in HTTP traffic can starve WebSocket delivery.

Use a Dedicated Broadcast Queue

// app/Events/OrderStatusUpdated.php
class OrderStatusUpdated implements ShouldBroadcastNow
{
    public function broadcastQueue(): string
    {
        return 'broadcasting';
    }
}

Then in config/horizon.php, give that queue its own supervisor pool:

'broadcasting' => [
    'connection' => 'redis',
    'queue' => ['broadcasting'],
    'balance' => 'auto',
    'minProcesses' => 2,
    'maxProcesses' => 10,
    'tries' => 3,
],

Using ShouldBroadcastNow skips the queue entirely for ultra-low-latency events where the payload is cheap to compute. Reserve it for lightweight events only — anything that hits the database should stay queued.


Channel Authorization Without the N+1 Trap

Private and presence channels call your authorization callbacks on every subscribe. If those callbacks issue unguarded Eloquent queries, a reconnect storm (e.g., after a deploy) will hammer your database.

Cache Authorization Results

// routes/channels.php
Broadcast::channel('orders.{orderId}', function (User $user, int $orderId) {
    $cacheKey = "channel_auth:{$user->id}:{$orderId}";

    return Cache::remember($cacheKey, now()->addMinutes(5), function () use ($user, $orderId) {
        return $user->can('view', Order::findOrFail($orderId));
    });
});

Five minutes is usually safe for order-level permissions. For presence channels that carry user metadata, cache the payload too:

Broadcast::channel('team.{teamId}', function (User $user, int $teamId) {
    $member = Cache::remember(
        "presence_auth:{$user->id}:{$teamId}",
        now()->addMinutes(2),
        fn () => $user->teams()->find($teamId)
    );

    return $member ? ['id' => $user->id, 'name' => $user->name] : false;
});

Horizontal Scaling with a Shared Redis Pub/Sub Backend

Reverb uses Redis pub/sub to fan out messages across multiple server instances. The key is ensuring every Reverb worker subscribes to the same Redis channel namespace.

# .env on each node
REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080
REVERB_SCALING_ENABLED=true
REVERB_SCALING_REDIS_HOST=redis-cluster.internal

Behind a load balancer, sticky sessions (IP hash or cookie-based) are not required when scaling is enabled — Reverb's Redis backend propagates messages to whichever node holds the connection. Confirm this in config/reverb.php:

'scaling' => [
    'driver' => 'redis',
    'redis' => [
        'connection' => 'reverb',
    ],
],

Define a dedicated Redis connection in config/database.php so Reverb's pub/sub traffic doesn't share a connection pool with your cache or session drivers.


Monitoring What Actually Matters

Reverb exposes a /apps/{appId}/connections endpoint (protected by your app secret) that returns current connection counts. Scrape it with a cron and push to your metrics store, or hook into Reverb's ConnectionEstablished and ConnectionClosed events:

Event::listen(\Laravel\Reverb\Events\ConnectionEstablished::class, function ($event) {
    Metrics::increment('reverb.connections.active');
});

Track queue depth on the broadcasting queue separately from connection count — a growing queue with stable connections means your event payload computation is the bottleneck, not Reverb itself.


Takeaways

  • Isolate broadcast events onto a dedicated Horizon queue pool to prevent starvation.
  • Cache channel authorization callbacks; reconnect storms will expose any unguarded query.
  • Enable REVERB_SCALING_ENABLED with a dedicated Redis connection for true horizontal scale.
  • Use ShouldBroadcastNow only for lightweight, database-free events.
  • Monitor queue depth and connection count independently — they point to different bottlenecks.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Do I need sticky sessions when running multiple Reverb nodes behind a load balancer?
No. When `REVERB_SCALING_ENABLED=true` is set and Reverb uses Redis pub/sub, messages are propagated to whichever node holds the client connection, so sticky sessions are unnecessary.
Q02 When should I use ShouldBroadcastNow instead of ShouldBroadcast?
Use ShouldBroadcastNow only for events whose payload requires no database queries and where sub-second delivery matters. Any event that touches the database should remain queued to avoid blocking the request cycle.
Q03 How long should I cache channel authorization results?
Two to five minutes is a reasonable default for most permission models. Shorter TTLs suit presence channels with frequently changing membership; longer TTLs suit stable resource-level permissions like order ownership.

Continue reading

More Articles

View all