Laravel Broadcasting — ShouldBroadcastNow Latency Cascade
API p99 latency spiked from 80ms to 12s during flash sale because ShouldBroadcastNow blocked workers.
- 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
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.
- 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.
- 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.
- 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).
- 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.
- 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.Key takeaways
Interview Questions on This Topic
Frequently Asked Questions
That's Laravel. Mark it forged?
8 min read · try the examples if you haven't