Laravel Broadcasting — ShouldBroadcastNow Latency Cascade
API p99 latency spiked from 80ms to 12s during flash sale because ShouldBroadcastNow blocked workers.
20+ years shipping production PHP systems at scale. Lessons pulled from things that broke in production.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- 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
Laravel Broadcasting is a first-party abstraction that lets your server-side PHP code push events to connected clients over WebSockets, bypassing the request-response cycle entirely. It exists because traditional HTTP polling or AJAX intervals are wasteful and laggy for features like live notifications, chat messages, or collaborative editing.
Under the hood, Broadcasting wraps PHP events into serialized payloads, pushes them through a queue driver (typically Redis or SQS), and then a dedicated WebSocket server (Pusher, Soketi, or Laravel Reverb) relays those payloads to subscribed browser clients via persistent TCP connections. The critical nuance: Laravel Broadcasting is not truly real-time—it's near-real-time, because every broadcast event must pass through a queue worker, a Redis pub/sub channel, and the WebSocket server before reaching the client.
This cascading latency becomes visible when you use ShouldBroadcastNow (which bypasses the queue but still incurs serialization and network hops) or when you stack multiple events in rapid succession. The system shines for UI updates that can tolerate 200–500ms delays, but fails for sub-100ms requirements like multiplayer game state or high-frequency trading tickers.
Alternatives include raw WebSocket libraries (Ratchet, Swoole) or dedicated real-time backends (Node.js with Socket.IO, Elixir with Phoenix Channels), which trade Laravel's elegant PHP-first developer experience for lower latency and higher throughput. In production, you must account for connection limits (Pusher caps at 100 concurrent per plan tier; self-hosted Soketi/Reverb scales horizontally with Redis clustering), memory per connection (~50–100KB idle), and the fact that PHP's shared-nothing architecture means you cannot maintain in-memory state across workers—forcing you to use Redis for presence channel user lists and event deduplication.
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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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.
Why Laravel Broadcasting Is Not Real-Time — And Why That Matters
Laravel Broadcasting is a pub/sub bridge that pushes server-side events to connected clients via WebSockets, using Redis as the default channel backend. The core mechanic: your PHP code fires an event (e.g., OrderShipped) that implements ShouldBroadcast, Laravel serializes it and publishes to Redis, then a Node-based socket server (Laravel Echo Server or Pusher) picks it up and fans it out to subscribed browser clients. This is not true real-time — it's a fire-and-forget message relay with inherent latency.
Key property: ShouldBroadcastNow queues the broadcast job onto the sync queue by default, bypassing the normal queue worker. This means the broadcast happens synchronously in the same request lifecycle — no delay from queue processing, but also no retry, no backpressure handling, and no worker scaling. The broadcast job is pushed to Redis and immediately consumed by the socket server, which then pushes to all connected clients subscribed to that channel. Typical round-trip: 50-150ms under light load, but degrades quickly under concurrency.
Use broadcasting when you need to update multiple clients (dashboards, notifications, live feeds) without polling. It's not for low-latency trading or gaming — those need dedicated WebSocket servers. The real value is decoupling: your HTTP controller fires an event and returns 200, while the broadcast happens asynchronously. But the 'asynchronous' part is deceptive — ShouldBroadcastNow's sync queue means the broadcast blocks the request until Redis acknowledges receipt. Under high concurrency, this creates a latency cascade that kills throughput.
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.
<?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'; } }
- 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.
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.
<?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 class — Laravel will resolve it from the container, | meaning you can inject repositories, services, and cache layers. */ Broadcast::channel('orders.{userId}', \App\Broadcasting\OrderChannel::class);
- 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.
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.
<?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). ], ], ];
- 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.
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 method subscribes to a public channel, channel() auto-hits private()/broadcasting/auth before subscribing, and subscribes to a presence channel and gives you three hooks: join() (initial member list), here() (new member), and joining() (member left). These three hooks are all you need to build a 'who is typing' indicator or a live attendee count.leaving()
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 . 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. broadcast()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/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.
- 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.
Echo.socketId() is only available after the WebSocket connection is established — wrap the header assignment in a 'connected' event binding to handle reconnection scenarios.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.
#!/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
- 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.
Synchronous Broadcasting Cascades Into 12-Second API Latency During Order Surge
- 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.
listen() callback never fires.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.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.grep -rn 'broadcastAs' app/Events/curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost/broadcasting/authphp artisan route:list --path=broadcastingphp artisan tinker --execute="dd(auth()->check(), auth()->id())"php artisan queue:monitor redis:defaultphp artisan queue:work --once --queue=broadcastsphp artisan config:show broadcasting.defaultsupervisorctl statusgrep -rn 'broadcast(' app/ | grep -v vendorgrep -rn 'new Echo' resources/js/broadcast() is called twice, remove the duplicate. If Echo is initialized twice, consolidate to one instance. Use broadcastToOthers() to prevent self-receiving.php artisan config:show reverb.options.heartbeat_intervalphp artisan reverb:connectionscurl -w 'Total: %{time_total}s\n' -o /dev/null -s https://api.pusherapp.com/appsphp artisan queue:failedgrep -rn 'wsHost\|forceTLS' resources/js/curl -sI https://yourdomain.com | grep -i strict-transport| Aspect | Laravel Reverb | Pusher (Managed) | Redis + Soketi |
|---|---|---|---|
| Infrastructure ownership | Self-hosted (your server) | Fully managed (Pusher's servers) | Self-hosted (WebSocket server + Redis) |
| Runtime requirement | PHP 8.2+ only | None (SaaS) | Node.js (Soketi) + Redis |
| Cost model | Server cost only | Per message + connection fee | Server + Redis cost |
| Pusher JS protocol | Yes — Echo unchanged | Yes — Echo unchanged | Yes — Echo unchanged |
| Horizontal scaling | Via Redis pub/sub + nginx | Handled by Pusher | Redis pub/sub handles fan-out |
| Debug tooling | php artisan reverb:start --debug | Pusher debug console (excellent) | Soketi metrics endpoint |
| Best for | New Laravel 11+ apps, privacy-first | Fast prototyping, startup scale | Existing Redis infra, data sovereignty |
| Max connections (free tier) | Unlimited (your hardware limit) | 100 (free tier) | Unlimited (your hardware limit) |
| TLS termination | Delegate to Nginx/Caddy proxy | Handled by Pusher | Delegate to Nginx/Caddy proxy |
| Ops burden | Medium — manage process, scaling, TLS | None — Pusher handles everything | High — manage Soketi + Redis + scaling |
| Data sovereignty | Full — data stays on your servers | Partial — data passes through Pusher | Full — data stays on your servers |
| Latency | Low — direct connection to your server | Variable — depends on Pusher edge nodes | Low — direct connection to your server |
| File | Command / Code | Purpose |
|---|---|---|
| app | namespace App\Events; | How Broadcasting Actually Works Under the Hood |
| routes | use App\Models\Order; | Public, Private and Presence Channels |
| config | return [ | Choosing Your Driver |
| resources | window.Pusher = Pusher; | Laravel Echo, Presence Channels and Real-Time DOM Updates En |
| io | cat /proc/$(pidof php)/limits | grep 'Max open files' | Production Scaling |
Key takeaways
Interview Questions on This Topic
Frequently Asked Questions
Yes — Laravel itself does not maintain persistent WebSocket connections; it publishes events to a driver that does. Your options are a managed service (Pusher, Ably), a self-hosted server (Laravel Reverb, Soketi), or Redis pub/sub paired with a compatible server. Laravel Reverb is the easiest self-hosted option: run php artisan reverb:start and you have a WebSocket server with zero additional runtimes.
The most common cause is a mismatch between the event name on the server and what Echo is listening for. If you defined broadcastAs() on your event, Echo requires a leading dot in listen(): .listen('.your.event.name'). Also verify your channel name matches exactly (including the 'private-' prefix Echo adds automatically for PrivateChannel), and check your browser's Network tab to confirm the /broadcasting/auth POST is returning 200, not 401 or 403.
event() dispatches a standard Laravel event that goes to registered Listeners in your EventServiceProvider — it stays entirely server-side. broadcast() (or using the ShouldBroadcast interface) additionally sends the event payload to a WebSocket driver so browser clients can receive it. An event can implement ShouldBroadcast and still have server-side Listeners — both happen independently.
Run multiple Reverb instances behind nginx with WebSocket support (proxy_set_header Upgrade, proxy_read_timeout 86400s). Use Redis pub/sub as the message bus between instances — all instances subscribe to the same Redis channels. Enable sticky sessions on the load balancer. Increase file descriptor limits (LimitNOFILE=65535). Monitor active connections, memory per connection, and file descriptor usage.
Echo's default auth mechanism uses session cookies. For token-based SPAs, you must configure a custom authorizer that attaches the Bearer token to the /broadcasting/auth POST request. Without this, every private channel subscription silently fails with a 401. See the Echo setup code in this article for the complete authorizer implementation.
For most new projects, start with Reverb. It is first-party, requires no additional runtime (PHP only), integrates with Laravel auth and queues, and has no per-message costs. Choose Pusher if you need zero infrastructure management, global edge nodes, or are in a rapid prototyping phase where engineering time is more valuable than Pusher's bill. You can always switch later — the application code is identical across drivers.
20+ years shipping production PHP systems at scale. Lessons pulled from things that broke in production.
That's Laravel. Mark it forged?
8 min read · try the examples if you haven't