Advanced 8 min · March 06, 2026

Laravel Broadcasting — ShouldBroadcastNow Latency Cascade

API p99 latency spiked from 80ms to 12s during flash sale because ShouldBroadcastNow blocked workers.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Server fires broadcast(new Event()) — event is serialized and handed to the configured driver
  • Driver (Reverb, Pusher, Redis+Soketi) pushes the message to every WebSocket subscribed to the channel
  • Laravel Echo (JS library) listens on channels and fires callbacks when events arrive
  • ShouldBroadcast — queued broadcast (async, recommended for production)
  • ShouldBroadcastNow — synchronous broadcast (blocks the web request)
  • PrivateChannel — requires /broadcasting/auth endpoint authorization
  • PresenceChannel — private channel that tracks who is online
  • broadcastAs() — overrides the event name for cleaner JS references
Plain-English First

Imagine a radio station. When the DJ says something, every radio tuned to that station hears it instantly — the DJ doesn't call each listener individually. Laravel Broadcasting works the same way: your server is the DJ, your browser is the radio, and a channel is the station frequency. The moment something interesting happens on your server (a new message, an order update, a live score), every browser 'tuned in' gets the update without refreshing the page.

Most web apps start request-response: the browser asks, the server answers, everyone goes home. That model collapses the moment your users expect live updates — a chat message that appears without a page reload, a dashboard that ticks in real time, a notification that pops up the second an order ships. Building that from scratch means wrestling with WebSockets, long-polling, reconnection logic, and heartbeat timers. It is a genuine pain, and it is exactly the problem Laravel Broadcasting was designed to erase.

Laravel Broadcasting is the layer that connects your server-side events to your browser-side JavaScript listeners through a persistent connection. You fire an event on the server, Broadcasting serialises it onto a channel, a WebSocket driver delivers it to every subscribed client, and Laravel Echo (the companion JS library) reacts to it — all without you writing a single line of WebSocket server code. The real genius is the abstraction: you can swap the underlying driver (Pusher, Ably, Redis/Soketi, or the new first-party Laravel Reverb) without touching your application logic.

By the end of this article you will understand exactly how a broadcasted event travels from a queued job all the way to a DOM update, how to choose and configure the right driver for your scale, how to lock down private and presence channels so sensitive data stays safe, and how to avoid the handful of production gotchas that catch even experienced Laravel engineers off guard.

How Broadcasting Actually Works Under the Hood

When you call broadcast(new OrderShipped($order)), a lot happens before any browser sees a thing. Laravel resolves the BroadcastManager, which picks the configured driver (Pusher, Reverb, Redis, etc.) from config/broadcasting.php. The event is serialised — by default via its broadcastWith() payload — and handed off to that driver's HTTP or socket API.

If your event implements ShouldBroadcastNow it is dispatched synchronously on the current process. If it implements ShouldBroadcast it is pushed onto your queue, which means the HTTP request returns immediately and the broadcast happens in a worker process. This distinction matters enormously under load: synchronous broadcasts block your web server, so ShouldBroadcast (async) is almost always the right choice in production.

On the client side, Laravel Echo opens a persistent WebSocket connection to whatever server the driver provides. Echo subscribes to a channel by name. When the driver receives a message for that channel name it pushes it down every open socket subscribed to it. Echo's event listener fires, your JavaScript callback runs, and the DOM updates. No polling, no page reload, no manual socket management.

The channel name is the routing key for the entire system. Get it wrong and messages silently disappear — which is responsible for more 'broadcasting is broken' support tickets than anything else.

The serialization lifecycle: When a broadcast event is dispatched, Laravel serializes the event using the SerializesModels trait. This converts Eloquent models to their class name and primary key. When the queue worker processes the job, it deserializes the model by querying the database. This means the model data in the broadcast payload comes from broadcastWith(), not from the serialized model — broadcastWith() is evaluated at queue processing time, not dispatch time. If the model is deleted between dispatch and processing, broadcastWith() may throw an exception.

app/Events/OrderShipped.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

// ShouldBroadcast tells Laravel to push this event through the broadcast driver.
// Using ShouldBroadcastNow would skip the queue — fine for low traffic, risky at scale.
class OrderShipped implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        // public properties are automatically included in broadcastWith() by default
        public readonly Order $order
    ) {}

    /**
     * Define WHICH channel(s) this event broadcasts on.
     * A PrivateChannel requires the client to be authorised — crucial for user-specific data.
     */
    public function broadcastOn(): array
    {
        return [
            // Channel name uses the order owner's ID so each user only sees their own updates.
            new PrivateChannel('orders.' . $this->order->user_id),
        ];
    }

    /**
     * Control exactly what data goes to the browser.
     * Never rely on the default — always be explicit to avoid leaking sensitive model attributes.
     */
    public function broadcastWith(): array
    {
        return [
            'order_id'     => $this->order->id,
            'status'       => $this->order->status,
            'updated_at'   => $this->order->updated_at->toIso8601String(),
            // Deliberately omitting payment_token, internal_notes, etc.
        ];
    }

    /**
     * Override the default event name (App\Events\OrderShipped) with something
     * clean that your JavaScript can reference without knowing PHP namespaces.
     */
    public function broadcastAs(): string
    {
        return 'order.shipped';
    }
}
Output
// No direct console output — this is an event class.
// When dispatched, the broadcasting driver logs:
// Broadcasting [order.shipped] on [private-orders.42]
// (visible with LOG_CHANNEL=stack and BROADCAST_DRIVER debug enabled)
Broadcasting as a Postal System
  • ShouldBroadcastNow makes a synchronous HTTP call to the driver API inside the web request. This blocks the worker.
  • Under load, the driver API may respond slowly (rate limiting, network latency). Each blocked worker reduces your throughput.
  • ShouldBroadcast pushes the job to the queue. The web request returns immediately. The broadcast happens asynchronously.
  • The trade-off: queued broadcasts have a slight delay (queue processing time, typically 10-50ms). This is imperceptible to users.
Production Insight
The broadcastWith() method is evaluated at queue processing time, not dispatch time. If the model is deleted between dispatch and processing, broadcastWith() may throw an exception because the model relationship is null. Handle this with a null check in broadcastWith() or use the model's attributes directly instead of relationships.
Key Takeaway
The broadcast pipeline is: event -> BroadcastManager -> driver API -> WebSocket -> Echo callback. ShouldBroadcast (queued) is the production default. ShouldBroadcastNow blocks the web request. broadcastWith() is evaluated at queue processing time — handle null models. The channel name is the routing key — get it wrong and messages silently disappear.
ShouldBroadcast vs ShouldBroadcastNow Selection
IfProduction endpoint with > 10 requests/minute
UseAlways use ShouldBroadcast (queued). Synchronous broadcasts block workers under load.
IfLow-traffic internal tool or admin notification
UseShouldBroadcastNow is acceptable. The latency is imperceptible and queue infrastructure is not needed.
IfEvent must be delivered before the HTTP response returns
UseUse ShouldBroadcastNow, but be aware of the performance impact. Consider if the requirement is truly necessary.
IfHigh-volume event (> 1000 broadcasts/minute)
UseUse ShouldBroadcast with batch mode ('batch' => true) to group multiple broadcasts into a single driver API call.

Public, Private and Presence Channels — Security Is Not Optional

Laravel Broadcasting has three channel types, and picking the wrong one is a security hole, not just an inconvenience.

Public channels (Channel) require zero authentication. Anyone who knows the channel name can subscribe. Use these only for genuinely public data: live sports scores, public leaderboards, site-wide announcements.

Private channels (PrivateChannel) require the client to hit your /broadcasting/auth endpoint before Echo will subscribe. Laravel checks the channel name against your routes/channels.php authorisation callbacks. If the callback returns false the connection is refused at the driver level. Use these for anything user-specific: order updates, private messages, account notifications.

Presence channels (PresenceChannel) are private channels with an extra superpower: they track who is currently subscribed. Every join and leave fires a membership event, and every subscriber can query the full member list. This makes them perfect for 'who is online in this room' features, collaborative editors, and live user counts.

The authorisation flow is: Echo sends a POST to /broadcasting/auth with the socket ID and channel name, Laravel runs your channel route callback, and if it returns truthy the driver issues a signed auth token that proves this socket is allowed on this channel. The token lives for the duration of the connection — it is not re-checked on every message.

Channel authorization security: The authorization callback is the only security boundary for private and presence channels. If the callback returns true for a user who should not have access, the user can subscribe and receive all messages on that channel. Always verify the authenticated user's relationship to the channel resource (e.g., does this user own this order?). Never return true unconditionally.

Class-based channel authorization: For complex authorization logic, extract the callback to a class. This enables unit testing, dependency injection (repositories, ACL services, cache layers), and cleaner code. Laravel resolves the class from the service container.

routes/channels.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<?php

// routes/channels.php — this is your channel-level authorization firewall.
// Return true/false for private channels, return a user data array for presence channels.

use App\Models\Order;
use App\Models\ChatRoom;
use Illuminate\Support\Facades\Broadcast;

/*
 |--------------------------------------------------------------------------
 | Private Channel: orders.{userId}
 |--------------------------------------------------------------------------
 | The {userId} wildcard is resolved and passed as a parameter.
 | The authenticated user object is always the first argument.
 | Return true only if the authenticated user owns this channel.
 */
Broadcast::channel('orders.{userId}', function ($authenticatedUser, int $userId) {
    // Strict integer comparison prevents type-juggling bypass (e.g. '1abc' == 1 in PHP)
    return $authenticatedUser->id === $userId;
});

/*
 |--------------------------------------------------------------------------
 | Presence Channel: chat-room.{roomId}
 |--------------------------------------------------------------------------
 | For presence channels, return an ARRAY of user data on success.
 | This array becomes the member info other subscribers can see.
 | Return false to deny access.
 */
Broadcast::channel('chat-room.{roomId}', function ($authenticatedUser, int $roomId) {
    $room = ChatRoom::find($roomId);

    // Guard: room must exist and user must be a member
    if (! $room || ! $room->hasMember($authenticatedUser)) {
        return false; // Echo will throw a subscription error on the client
    }

    // The array you return becomes the member object other users can inspect.
    // Only expose what other room members should actually see.
    return [
        'id'     => $authenticatedUser->id,
        'name'   => $authenticatedUser->display_name,
        'avatar' => $authenticatedUser->avatar_url,
    ];
});

/*
 |--------------------------------------------------------------------------
 | Class-based channel authorisation (cleaner for complex logic)
 |--------------------------------------------------------------------------
 | Instead of a closure, point to a classLaravel will resolve it from the container,
 | meaning you can inject repositories, services, and cache layers.
 */
Broadcast::channel('orders.{userId}', \App\Broadcasting\OrderChannel::class);
Output
// When a client attempts to subscribe to private-orders.42 as user 99:
// POST /broadcasting/auth → 403 Forbidden
// Echo client fires: .subscription_error event
// When client subscribes as user 42:
// POST /broadcasting/auth → 200 OK
// {"auth": "app_key:signed_hmac_token", "channel_data": null}
Channel Types as Room Access Levels
  • Public channels have zero authentication. Anyone who knows the channel name can subscribe.
  • If user-specific data (order updates, private messages) is broadcast on a public channel, any user can subscribe and see other users' data.
  • This is a data leak — equivalent to exposing an API endpoint without authentication.
  • Always use PrivateChannel for user-specific data. The channel callback is the only security boundary.
Production Insight
The auth token is issued once at subscription time and is not re-checked on every message. If a user's permissions change after they subscribed (e.g., removed from a chat room), they continue receiving messages until they unsubscribe or the WebSocket connection drops. For high-security environments, implement a server-side mechanism to forcibly disconnect users when permissions change (Reverb's connection management API or a custom disconnect event).
Key Takeaway
Public channels have zero auth — only for genuinely public data. Private channels require /broadcasting/auth authorization. Presence channels track who is online. The channel callback is the only security boundary — always verify the user's relationship to the resource. Extract complex auth to classes for testability and DI.

Choosing Your Driver: Pusher vs Redis/Soketi vs Laravel Reverb

The driver is the WebSocket server that sits between Laravel and the browser. Your application code stays identical across drivers — only the config changes. But the operational implications are wildly different.

Pusher / Ably are managed services. You pay per message and per connection. Zero infrastructure to run, excellent dashboards, global edge nodes. Perfect for startups and apps where engineering time is more expensive than Pusher's bill. The hard limits (100 connections on free tier, message rate caps) become painful fast on high-traffic apps.

Redis + Soketi (or the older laravel-websockets package) runs your own WebSocket server, using Redis pub/sub as the message bus. Your Laravel app publishes to a Redis channel, Soketi subscribes and pushes to clients. This is Pusher-protocol-compatible, meaning your existing Pusher-flavoured Echo config works unchanged. Great for privacy-sensitive data and when you need horizontal scaling, but you own the ops burden.

Laravel Reverb (Laravel 11+) is the first first-party WebSocket server. It is written in PHP using ReactPHP/Amp event loops, meaning it runs as a long-lived PHP process rather than Node.js. It speaks the Pusher protocol so Echo requires no changes. For most applications it is now the default recommendation: single binary, php artisan reverb:start, no Node runtime, integrates with Octane for maximum throughput.

The choice comes down to three questions: do you want zero ops overhead (Pusher/Ably), full data sovereignty (Reverb/Soketi), or are you already on a Redis-heavy stack?

Scaling considerations: Pusher handles scaling for you — but you pay per connection. Reverb and Soketi require you to manage scaling — but you pay only for server costs. For horizontal scaling with Reverb, you need Redis as a pub/sub bus between multiple Reverb instances. Without Redis, each Reverb instance is independent and clients connected to different instances will not receive each other's broadcasts.

Dedicated Redis connection for broadcasting: If using the Redis driver, always define a dedicated Redis connection. Broadcasting uses SUBSCRIBE/PUBLISH commands that occupy a Redis connection differently from GET/SET (cache, sessions, queues). A shared connection will experience head-of-line blocking under load.

config/broadcasting.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
<?php

// config/broadcasting.php — annotated production configuration
// Switch drivers by changing BROADCAST_DRIVER in your .env — zero code changes needed.

return [

    /*
    |--------------------------------------------------------------------------
    | Default Broadcaster
    |--------------------------------------------------------------------------
    | Options: 'reverb' | 'pusher' | 'ably' | 'redis' | 'log' | 'null'
    | Use 'log' locally to see broadcast output in your log file without a WebSocket server.
    | Use 'null' in automated tests to prevent real broadcasts.
    */
    'default' => env('BROADCAST_DRIVER', 'log'),

    'connections' => [

        /*
         * Laravel Reverb — first-party, zero extra runtime required.
         * Run: php artisan reverb:start (add --debug in development)
         * For production: run behind Nginx reverse-proxy with TLS termination.
         */
        'reverb' => [
            'driver'  => 'reverb',
            'key'     => env('REVERB_APP_KEY'),
            'secret'  => env('REVERB_APP_SECRET'),
            'app_id'  => env('REVERB_APP_ID'),
            'options' => [
                'host'   => env('REVERB_HOST', '0.0.0.0'),
                'port'   => env('REVERB_PORT', 8080),
                'scheme' => env('REVERB_SCHEME', 'https'), // always https in production
                // 'useTLS' handled by your Nginx/Caddy proxy — Reverb speaks plain HTTP internally
            ],
            // Reverb respects your queue connection for async broadcasts
            'client_options' => [],
        ],

        /*
         * Pusher — managed WebSocket-as-a-service.
         * Set PUSHER_* vars from your Pusher dashboard.
         */
        'pusher' => [
            'driver'  => 'pusher',
            'key'     => env('PUSHER_APP_KEY'),
            'secret'  => env('PUSHER_APP_SECRET'),
            'app_id'  => env('PUSHER_APP_ID'),
            'options' => [
                'cluster'   => env('PUSHER_APP_CLUSTER', 'mt1'),
                'encrypted' => true, // never set this to false in production
                /*
                 * Performance tuning: batch multiple broadcasts in one HTTP request.
                 * Reduces RTT when firing many events in a single request lifecycle.
                 */
                'batch'     => true,
                'curl_options' => [
                    CURLOPT_CONNECTTIMEOUT => 3, // fail fast rather than blocking workers
                    CURLOPT_TIMEOUT        => 5,
                ],
            ],
        ],

        /*
         * Redis driver — pairs with Soketi or a compatible Pusher-protocol server.
         * Your Laravel app publishes to Redis; the WebSocket server consumes and forwards.
         */
        'redis' => [
            'driver'     => 'redis',
            'connection' => env('BROADCAST_REDIS_CONNECTION', 'default'),
            // Use a dedicated Redis connection for broadcasts to avoid HOL blocking
            // on your general-purpose Redis connection (cache, sessions, queues).
        ],

    ],

];
Output
// With BROADCAST_DRIVER=log in .env:
// [2024-06-01 10:23:45] local.INFO: Broadcasting [order.shipped] on channels [private-orders.42] with payload:
// {"order_id":42,"status":"shipped","updated_at":"2024-06-01T10:23:45+00:00"}
Drivers as Delivery Services
  • Reverb: self-hosted, no per-message costs, full data sovereignty, PHP-native (one runtime). Best for privacy-sensitive data and cost optimization.
  • Pusher: zero ops, global edge nodes, excellent dashboard. Best for startups and teams without DevOps capacity.
  • The crossover point: when your Pusher bill exceeds the cost of a dedicated server, switch to Reverb.
  • For Laravel 11+ new projects, Reverb is the default recommendation unless you have a specific reason to use Pusher.
Production Insight
Not isolating the Redis broadcast connection is the most common Redis driver misconfiguration. SUBSCRIBE/PUBLISH commands hold the connection open and block other operations. Under high broadcast volume, your cache reads and queue operations slow down because they share the same Redis connection. Define a dedicated 'broadcast' Redis connection and point BROADCAST_REDIS_CONNECTION at it.
Key Takeaway
Driver choice is an ops decision, not a code decision. Application code is identical across drivers. Reverb is the default for Laravel 11+ (zero ops, PHP-native). Pusher for zero-infrastructure startups. Redis+Soketi for data sovereignty. Always use a dedicated Redis connection for broadcasting to prevent HOL blocking.
Driver Selection Strategy
IfNew Laravel 11+ project, no existing WebSocket infrastructure
UseLaravel Reverb. First-party, zero extra runtime, integrates with Laravel auth and queues.
IfStartup with limited DevOps, need to ship fast
UsePusher or Ably. Zero infrastructure, excellent dashboards, scale as you grow.
IfExisting Redis infrastructure, privacy-sensitive data
UseRedis + Soketi. Data stays on your infrastructure, Pusher-protocol compatible.
IfHigh-traffic app (> 10K concurrent connections)
UseReverb with Redis pub/sub for horizontal scaling. Or Pusher if ops cost exceeds Pusher bill.

Laravel Echo, Presence Channels and Real-Time DOM Updates End to End

Laravel Echo is the JavaScript counterpart that closes the loop. It wraps the Pusher JS SDK (or the Reverb connector) and gives you a clean, fluent API for subscribing to channels and listening to events. Without Echo you would have to manage socket IDs, channel auth tokens, and reconnection logic yourself.

The channel() method subscribes to a public channel, private() auto-hits /broadcasting/auth before subscribing, and join() subscribes to a presence channel and gives you three hooks: here() (initial member list), joining() (new member), and leaving() (member left). These three hooks are all you need to build a 'who is typing' indicator or a live attendee count.

One production subtlety that trips people up: Echo's WebSocket connection is per-tab, but your /broadcasting/auth route uses your session cookie or Bearer token. In a SPA with token-based auth you must tell Echo's axios instance to include the Authorization header on every auth request. Forgetting this means every private channel subscription silently fails with a 401, yet the public WebSocket connection remains open — making it look like the driver is working when it is not.

Another nuance: broadcastToOthers() vs broadcast(). When a user triggers an action that broadcasts back to them too, you often get a ghost update: the UI changes optimistically via JavaScript, then the broadcast arrives and changes it again. broadcastToOthers() sends to every subscriber except the socket that triggered the action, preventing the double-update.

Reconnection handling: WebSocket connections drop — network hiccups, server restarts, load balancer timeouts. Echo handles reconnection automatically, but you should handle the reconnection event in your UI: show a 'reconnecting...' indicator, buffer user actions during disconnection, and re-sync state from the server after reconnection. Do not assume the WebSocket connection is permanent.

Echo lifecycle in SPAs: In single-page applications, Echo is initialized once in the app entry point. When navigating between pages, you must unsubscribe from channels that are no longer relevant to prevent memory leaks and unnecessary message processing. Use Echo.leave('channel-name') in component teardown hooks (Vue unmounted, React useEffect cleanup).

resources/js/echo-setup.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// resources/js/bootstrap.js (or your entry point)
// Full Echo setup for a Laravel Reverb backend with Sanctum SPA authentication.

import Echo from 'laravel-echo';
import Pusher from 'pusher-js'; // Reverb reuses the Pusher JS client under the hood

// Make Pusher available globally — Echo looks for window.Pusher
window.Pusher = Pusher;

window.Echo = new Echo({
    broadcaster: 'reverb', // or 'pusher' — same Pusher JS client either way
    key: import.meta.env.VITE_REVERB_APP_KEY,
    wsHost: import.meta.env.VITE_REVERB_HOST,
    wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
    wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
    forceTLS: import.meta.env.VITE_REVERB_SCHEME === 'https',
    enabledTransports: ['ws', 'wss'], // disable long-polling fallback for cleaner failure modes

    // CRITICAL for SPA/token auth: attach Bearer token to the channel auth request.
    // Without this, every private channel POST to /broadcasting/auth returns 401.
    authorizer: (channel) => ({
        authorize: (socketId, callback) => {
            window.axios.post('/broadcasting/auth', {
                socket_id:    socketId,
                channel_name: channel.name,
            }, {
                headers: {
                    Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
                },
            })
            .then(response => callback(false, response.data))
            .catch(error  => callback(true, error));
        },
    }),
});


// --- Listening to a Private Channel ---
// resources/js/pages/OrderTracking.vue (or any component)

const currentUserId = window.authUser.id; // set by your Blade layout or API

// .private() triggers the /broadcasting/auth check automatically
window.Echo
    .private(`orders.${currentUserId}`)
    .listen('.order.shipped', (eventPayload) => {
        // The leading dot means we're using the broadcastAs() name, not the class name.
        // Without the dot Echo looks for App\Events\OrderShipped as the event name.
        console.log('Order update received:', eventPayload);
        // eventPayload = { order_id: 42, status: 'shipped', updated_at: '...' }

        updateOrderStatusInUI(eventPayload.order_id, eventPayload.status);
    });


// --- Presence Channel: Live Room Membership ---
window.Echo
    .join(`chat-room.${roomId}`)
    .here((members) => {
        // Called once on successful subscription with the full current member list
        // members = [{ id: 1, name: 'Alice', avatar: '...' }, { id: 2, name: 'Bob', avatar: '...' }]
        renderMemberList(members);
    })
    .joining((newMember) => {
        // Called whenever someone else subscribes to this channel
        addMemberToList(newMember);
        showToast(`${newMember.name} joined the room`);
    })
    .leaving((departedMember) => {
        // Called when a member's WebSocket disconnects or they explicitly unsubscribe
        removeMemberFromList(departedMember.id);
    })
    .listen('.new.message', (message) => {
        appendMessageToChat(message);
    });


// --- broadcastToOthers: Prevent Ghost Updates ---
// In your controller, instead of:
//   broadcast(new MessageSent($message));
// Use:
//   broadcast(new MessageSent($message))->toOthers();
//
// This tells the driver to exclude the socket ID that made this HTTP request,
// preventing the user who sent the message from receiving their own broadcast
// and double-rendering it alongside the optimistic UI update.
//
// Requires the X-Socket-ID header on your axios requests:

window.axios.defaults.headers.common['X-Socket-ID'] = window.Echo.socketId();
// Echo.socketId() is available only AFTER the connection is established.
// Wrap in Echo.connector.pusher.connection.bind('connected', () => { ... }) if needed.
Output
// Browser Console when OrderShipped is dispatched for user 42:
// Order update received: { order_id: 42, status: 'shipped', updated_at: '2024-06-01T10:23:45+00:00' }
// Presence channel join for chat-room.7:
// here() called with: [{ id: 1, name: 'Alice', avatar: 'https://...' }, { id: 3, name: 'Carol', avatar: 'https://...' }]
// joining() called with: { id: 5, name: 'Dave', avatar: 'https://...' }
Echo as a Telephone Operator
  • Echo subscriptions persist for the lifetime of the WebSocket connection — they survive page navigation in SPAs.
  • Without cleanup, you accumulate channel subscriptions as users navigate. Each subscription processes every message on that channel.
  • This causes memory leaks (callbacks referencing destroyed components) and duplicate UI updates.
  • Always call Echo.leave('channel-name') in Vue unmounted or React useEffect cleanup.
Production Insight
The X-Socket-ID header is required for broadcastToOthers() to work. Without it, the server cannot identify which socket to exclude, and the sender receives their own broadcast (ghost update). Echo.socketId() is only available after the WebSocket connection is established — wrap the header assignment in a 'connected' event binding to handle reconnection scenarios.
Key Takeaway
Echo wraps the Pusher JS SDK with a clean API for channel subscription and event listening. The leading dot in .listen('.event.name') is mandatory when using broadcastAs(). broadcastToOthers() prevents ghost updates. Always unsubscribe channels in SPA teardown hooks. Handle reconnection events in the UI.

Production Scaling: Connection Limits, Memory, and Horizontal Scaling

WebSocket servers maintain persistent connections — each connection consumes memory and file descriptors. Understanding the resource model is critical for production scaling.

Connection limits: Each WebSocket connection consumes approximately 50-100KB of memory (buffer space, channel subscriptions, connection state). A server with 8GB RAM can theoretically handle 80,000-160,000 concurrent connections. In practice, the limit is lower due to file descriptor limits (ulimit -n), kernel TCP buffer allocation, and application-level overhead.

File descriptor limits: Each WebSocket connection uses one file descriptor. The default ulimit -n is typically 1024 on Linux — this limits you to ~1000 connections. Increase it: ulimit -n 65535 or set LimitNOFILE=65535 in the systemd unit file. Verify with: cat /proc/$(pidof php)/limits | grep 'Max open files'.

Horizontal scaling with Redis pub/sub: Multiple Reverb/Soketi instances can share the same Redis pub/sub bus. When a broadcast is published to Redis, all subscribed instances receive it and forward to their connected clients. This enables horizontal scaling — add more instances to handle more connections. The trade-off: Redis becomes a single point of failure. Use Redis Sentinel or Redis Cluster for high availability.

Load balancer configuration: WebSocket connections are long-lived (minutes to hours). Load balancers must be configured to support WebSocket upgrade headers and avoid connection timeouts. In nginx: proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 86400s;. Without these settings, the load balancer kills idle WebSocket connections after the default timeout (typically 60 seconds).

Monitoring: Track: active connections (Echo.connector.pusher.connection.state), messages per second, connection churn rate (connects + disconnects per minute), memory per connection, and file descriptor usage. Alert when connections exceed 80% of the server's capacity.

io/thecodeforge/broadcasting-scaling.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
#!/bin/bash
# Production scaling configuration and monitoring for Laravel Broadcasting

# ── File descriptor limits ──────────────────────────────────────────────────

# Check current limit
cat /proc/$(pidof php)/limits | grep 'Max open files'
# Max open files: 1024 65535  (soft, hard)

# Increase for the Reverb process
sudo tee /etc/security/limits.d/reverb.conf <<EOF
www-data soft nofile 65535
www-data hard nofile 65535
EOF

# Or in systemd unit file:
sudo tee /etc/systemd/system/reverb.service <<EOF
[Unit]
Description=Laravel Reverb WebSocket Server
After=network.target

[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/app
ExecStart=/usr/bin/php artisan reverb:start
Restart=always
RestartSec=5
LimitNOFILE=65535
MemoryMax=2G

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable reverb
sudo systemctl start reverb

# ── nginx WebSocket configuration ───────────────────────────────────────────

cat <<'EOF' > /etc/nginx/sites-available/reverb
upstream reverb_backend {
    server 127.0.0.1:8080;
    # Add more Reverb instances for horizontal scaling:
    # server 127.0.0.1:8081;
    # server 127.0.0.1:8082;
}

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

    ssl_certificate /etc/ssl/certs/ws.example.com.pem;
    ssl_certificate_key /etc/ssl/private/ws.example.com.key;

    location / {
        proxy_pass http://reverb_backend;

        # WebSocket support — REQUIRED
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        # Pass real client IP
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Keep connections alive for 24 hours
        # Without this, nginx kills idle WebSocket connections after 60s
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;
    }
}
EOF

sudo nginx -t && sudo systemctl reload nginx

# ── Monitoring commands ─────────────────────────────────────────────────────

# Check active Reverb connections
php artisan reverb:connections

# Check file descriptor usage for the Reverb process
ls /proc/$(pidof php | head -1)/fd | wc -l

# Check memory usage
ps aux | grep 'reverb' | awk '{print $6/1024 " MB"}'

# Monitor connection rate (connects + disconnects per minute)
sudo tcpdump -i lo -n port 8080 2>/dev/null | grep -c 'FIN\|SYN' 
# Run for 60 seconds, divide by 60 for rate per second
Output
# File descriptor check:
Max open files: 65535 65535
# Active connections:
42,847 active connections across 3 Reverb instances
# Memory usage:
1247 MB (Reverb process with 42K connections)
# Connection rate:
~150 connects/disconnects per minute
WebSocket Scaling as a Telephone Exchange
  • WebSocket connections start as HTTP requests with an Upgrade header. The load balancer must forward this upgrade, not terminate it.
  • WebSocket connections are long-lived (minutes to hours). Load balancers default to 60-second timeouts, killing idle connections.
  • Sticky sessions (session affinity) are recommended — a client should stay connected to the same Reverb instance.
  • Without sticky sessions, a client may reconnect to a different instance and lose its channel subscriptions.
Production Insight
The file descriptor limit is the most common scaling bottleneck. Each WebSocket connection uses one file descriptor. The default ulimit -n of 1024 limits you to ~1000 connections. Increase to 65535 in the systemd unit file. Monitor with: ls /proc/$(pidof php)/fd | wc -l. Alert when usage exceeds 80% of the limit.
Key Takeaway
Each WebSocket connection uses ~50-100KB memory and one file descriptor. Increase ulimit -n to 65535 for production. Horizontal scaling requires Redis pub/sub between instances and sticky sessions on the load balancer. Configure nginx proxy_read_timeout=86400s for WebSocket support. Monitor active connections, file descriptors, and memory per connection.
Scaling Strategy by Connection Count
If< 1,000 concurrent connections
UseSingle Reverb instance. No horizontal scaling needed. Increase ulimit -n to 65535.
If1,000 - 50,000 concurrent connections
UseMultiple Reverb instances behind nginx with WebSocket support. Redis pub/sub for inter-instance communication.
If> 50,000 concurrent connections
UseConsider Pusher (managed scaling) or a dedicated WebSocket infrastructure team. Redis Cluster for HA.
IfConnection spikes (flash sales, live events)
UsePre-scale Reverb instances before the event. Monitor connection rate and auto-scale based on file descriptor usage.
● Production incidentPOST-MORTEMseverity: high

Synchronous Broadcasting Cascades Into 12-Second API Latency During Order Surge

Symptom
API p99 latency spiked from 80ms to 12 seconds during the flash sale. nginx error logs showed upstream timeout errors. Application logs showed Pusher HTTP timeouts (CURLOPT_TIMEOUT=30s exceeded). Queue workers were idle — no jobs were being dispatched because ShouldBroadcastNow bypasses the queue entirely. The load balancer health checks started failing because backends could not respond within the 5-second timeout.
Assumption
The team assumed a database bottleneck — they checked slow query logs (normal). They assumed a Redis bottleneck — they checked Redis latency (normal). They assumed a Pusher outage — Pusher status page showed no incidents. They assumed insufficient server resources — CPU was at 40%, memory at 60%. The actual issue was architectural: ShouldBroadcastNow made a synchronous HTTP call to the Pusher API inside every web request, and under load the Pusher API latency increased from 50ms to 500ms.
Root cause
The event class implemented ShouldBroadcastNow instead of ShouldBroadcast. Every order confirmation triggered a synchronous HTTP POST to the Pusher API. Under normal load (10 orders/minute), the Pusher API responded in 50ms — imperceptible. Under flash sale load (2000 orders/minute), the Pusher API rate-limited requests, causing 500ms-2s response times. Each web worker blocked on the Pusher API call, exhausting the worker pool (20 workers handling 2000 requests/minute = 100 requests/second = each worker handling 5 requests/second, but each request now takes 2 seconds = 10 seconds of worker time per request). The worker pool was exhausted within 30 seconds.
Fix
1. Changed all events from ShouldBroadcastNow to ShouldBroadcast — broadcasts are now dispatched to the queue. 2. Ensured queue workers were running with sufficient concurrency: php artisan queue:work --queue=broadcasts,default --max-jobs=1000. 3. Added CURLOPT_CONNECTTIMEOUT=3 and CURLOPT_TIMEOUT=5 to the Pusher config to fail fast. 4. Added batch mode ('batch' => true) to group multiple broadcasts into a single HTTP request. 5. Added monitoring: alert when broadcast queue depth exceeds 1000 jobs. 6. Load tested with 5000 orders/minute to verify the fix held under 2.5x expected peak.
Key lesson
  • ShouldBroadcastNow is a synchronous HTTP call inside your web request. Under load, this blocks workers and cascades into timeouts.
  • Always use ShouldBroadcast (queued) in production. The HTTP request returns immediately and the broadcast happens in a worker process.
  • Set CURLOPT_TIMEOUT=5 on the driver config to fail fast rather than blocking workers for 30 seconds on a slow driver API.
  • Enable batch mode ('batch' => true) to group multiple broadcasts into a single HTTP request — reduces RTT when firing many events in one request lifecycle.
  • Monitor broadcast queue depth. If the queue grows faster than workers process it, you need more workers or a different driver.
Production debug guideFrom silent listener failures to auth endpoint 401s — systematic debugging paths for real-time broadcasting problems.6 entries
Symptom · 01
WebSocket connection is open but listen() callback never fires.
Fix
Check four things in order: (1) Is the event name correct? If broadcastAs() is defined, Echo needs a leading dot: .listen('.event.name'). (2) Is the channel name correct? Private channels auto-prefix 'private-' — do not add it manually. (3) Is the /broadcasting/auth endpoint returning 200? Check the Network tab for the POST request. (4) Is the event implementing ShouldBroadcast (not just ShouldBroadcast)?
Symptom · 02
Private channel subscription fails with 401 or 403.
Fix
Check if the /broadcasting/auth route is registered: php artisan route:list --path=broadcasting. Check if the user is authenticated when the auth request arrives (session cookie or Bearer token). For SPAs, check if the Authorization header is attached to the auth POST. Check routes/channels.php for the channel callback — does it return truthy for this user?
Symptom · 03
Broadcasts are delayed by several seconds.
Fix
Check if the event uses ShouldBroadcast (queued) — if so, check queue worker status: php artisan queue:monitor. Check queue depth: php artisan queue:work --once to see if jobs are backing up. Check if the driver API (Pusher/Reverb) is responding slowly: curl -w '%{time_total}' to the driver endpoint. Check Redis connection if using Redis driver — is it shared with cache/sessions?
Symptom · 04
Presence channel shows stale member list — users who left are still listed.
Fix
Check the WebSocket heartbeat interval. If the connection drops without a clean close, the server may not fire the leaving() callback until the heartbeat timeout expires (typically 30-60 seconds). Check if the user's browser tab was closed (no clean disconnect). Check the Reverb/Pusher connection timeout configuration.
Symptom · 05
Broadcasts work locally but fail in production.
Fix
Check if BROADCAST_DRIVER is set correctly in production .env (not 'log' or 'null'). Check if the queue worker is running in production: supervisorctl status. Check if the WebSocket server (Reverb) is running: systemctl status reverb. Check if TLS is configured correctly — browsers block mixed-content WebSocket connections (wss:// required on https:// pages).
Symptom · 06
Duplicate events received by the browser — same event fires twice.
Fix
Check if the event is being dispatched twice in the code (duplicate broadcast() call). Check if the browser has multiple Echo connections (multiple tabs, or Echo initialized twice). Check if the WebSocket reconnected and replayed messages. Use broadcastToOthers() to prevent the sender from receiving their own broadcast.
★ Laravel Broadcasting Triage Cheat SheetFirst-response commands when broadcasts are silent, delayed, or producing auth errors.
WebSocket connected but listen() never fires.
Immediate action
Check event name, channel name, and auth endpoint.
Commands
grep -rn 'broadcastAs' app/Events/
curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost/broadcasting/auth
Fix now
If broadcastAs() is defined, add leading dot in .listen('.event.name'). If auth returns 401, check Bearer token or session cookie.
Private channel subscription returns 403.+
Immediate action
Check channel authorization callback in routes/channels.php.
Commands
php artisan route:list --path=broadcasting
php artisan tinker --execute="dd(auth()->check(), auth()->id())"
Fix now
If route is missing, add Broadcast::routes() to routes/web.php. If callback returns false, fix the authorization logic.
Broadcasts delayed by 5+ seconds.+
Immediate action
Check queue worker status and broadcast queue depth.
Commands
php artisan queue:monitor redis:default
php artisan queue:work --once --queue=broadcasts
Fix now
If queue is backed up, scale workers. If event uses ShouldBroadcastNow, switch to ShouldBroadcast.
Broadcasts work locally but fail in production.+
Immediate action
Check BROADCAST_DRIVER and queue worker status.
Commands
php artisan config:show broadcasting.default
supervisorctl status
Fix now
If driver is 'log' or 'null', fix BROADCAST_DRIVER in .env. If workers are not running, start supervisor: supervisorctl start all.
Duplicate events received in browser.+
Immediate action
Check for duplicate broadcast() calls and multiple Echo instances.
Commands
grep -rn 'broadcast(' app/ | grep -v vendor
grep -rn 'new Echo' resources/js/
Fix now
If broadcast() is called twice, remove the duplicate. If Echo is initialized twice, consolidate to one instance. Use broadcastToOthers() to prevent self-receiving.
Presence channel shows stale members after they leave.+
Immediate action
Check WebSocket heartbeat and connection timeout settings.
Commands
php artisan config:show reverb.options.heartbeat_interval
php artisan reverb:connections
Fix now
If heartbeat interval is too long (60s+), reduce to 30s. Stale members are cleaned up after the heartbeat timeout expires.
Pusher/Reverb API calls timing out — broadcast jobs failing.+
Immediate action
Check driver API connectivity and timeout configuration.
Commands
curl -w 'Total: %{time_total}s\n' -o /dev/null -s https://api.pusherapp.com/apps
php artisan queue:failed
Fix now
If API is slow, set CURLOPT_CONNECTTIMEOUT=3 and CURLOPT_TIMEOUT=5. If Reverb is down, restart: php artisan reverb:restart.
Mixed-content error — WebSocket connection blocked by browser.+
Immediate action
Check if page is HTTPS but WebSocket is ws:// (not wss://).
Commands
grep -rn 'wsHost\|forceTLS' resources/js/
curl -sI https://yourdomain.com | grep -i strict-transport
Fix now
Set forceTLS: true in Echo config. Ensure Reverb/Pusher is behind a TLS-terminating reverse proxy.
Laravel Broadcasting Drivers: Complete Comparison
AspectLaravel ReverbPusher (Managed)Redis + Soketi
Infrastructure ownershipSelf-hosted (your server)Fully managed (Pusher's servers)Self-hosted (WebSocket server + Redis)
Runtime requirementPHP 8.2+ onlyNone (SaaS)Node.js (Soketi) + Redis
Cost modelServer cost onlyPer message + connection feeServer + Redis cost
Pusher JS protocolYes — Echo unchangedYes — Echo unchangedYes — Echo unchanged
Horizontal scalingVia Redis pub/sub + nginxHandled by PusherRedis pub/sub handles fan-out
Debug toolingphp artisan reverb:start --debugPusher debug console (excellent)Soketi metrics endpoint
Best forNew Laravel 11+ apps, privacy-firstFast prototyping, startup scaleExisting Redis infra, data sovereignty
Max connections (free tier)Unlimited (your hardware limit)100 (free tier)Unlimited (your hardware limit)
TLS terminationDelegate to Nginx/Caddy proxyHandled by PusherDelegate to Nginx/Caddy proxy
Ops burdenMedium — manage process, scaling, TLSNone — Pusher handles everythingHigh — manage Soketi + Redis + scaling
Data sovereigntyFull — data stays on your serversPartial — data passes through PusherFull — data stays on your servers
LatencyLow — direct connection to your serverVariable — depends on Pusher edge nodesLow — direct connection to your server

Key takeaways

1
ShouldBroadcast (queued) vs ShouldBroadcastNow (synchronous) is not a minor detail
under load, synchronous broadcasting blocks your web workers and causes cascading timeouts. Always queue in production.
2
Channel security lives entirely in routes/channels.php. A Public channel has zero auth
never put user-specific data on one. Private channels require a truthy return from your callback; Presence channels require a data array so members can identify each other.
3
The leading dot in Echo's .listen('.event.name') is mandatory when you override broadcastAs(). Without it Echo silently uses the PHP class name and your listener never fires
no error, just silence.
4
Laravel Reverb is now the zero-ops default for new projects
one PHP process, no Node runtime, full Pusher-protocol compatibility, and it integrates directly with your existing Laravel auth and queue infrastructure.
5
Each WebSocket connection uses one file descriptor and ~50-100KB memory. Increase ulimit -n to 65535 for production. Horizontal scaling requires Redis pub/sub between instances.
6
Always define broadcastWith() explicitly to avoid leaking sensitive model attributes. Always unsubscribe channels in SPA teardown hooks. Always configure nginx for WebSocket support with proxy_read_timeout=86400s.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Do I need a separate WebSocket server to use Laravel Broadcasting?
02
Why is my broadcast event firing but the JavaScript listener never triggering?
03
What is the difference between broadcast() and event() in Laravel?
04
How do I scale Laravel Broadcasting to handle 50,000 concurrent connections?
05
How do I handle authentication for WebSocket connections in a SPA?
06
Should I use Laravel Reverb or Pusher for a new Laravel 11 project?
🔥

That's Laravel. Mark it forged?

8 min read · try the examples if you haven't

Previous
Laravel Sanctum API Authentication
15 / 15 · Laravel
Next
Composer and Autoloading in PHP