Livewire Polling, SSE, or WebSockets: Stop Defaulting to the JS Stack
Before you reach for React and a WebSocket server, ask whether your real-time UI actually needs any of that. Usually it doesn't.
Every time a client asks for a "live" dashboard, I watch the same reflex kick in on every team that hasn't shipped a lot of these: reach for React, add a WebSocket server, wire up Socket.io, and suddenly a three-day feature is a three-week infrastructure project. I've been that person. I'm not anymore.
The real question isn't how to make a UI update in real time. It's what kind of real-time do you actually need? The answer almost always points to something simpler than a full duplex socket layer, and if you're already in a Laravel/Livewire stack, you might not need to leave PHP-land at all.
The Three Primitives, Plainly
Before I pick anything I want to be honest about what each one actually is.
Livewire polling is a timer that fires an AJAX request from the component every N seconds, re-renders the component server-side, and diffs the DOM. It's a cron job you can see. Stupid simple. Zero infrastructure beyond what you already have.
Server-Sent Events (SSE) is a persistent HTTP connection where the server pushes text frames to the browser using the text/event-stream content type. One direction: server to client. The browser has a native EventSource API that handles reconnection automatically. No library required.
WebSockets are full-duplex. The client can send, the server can push, both sides talk whenever they want. This is the right tool for collaborative editing, multiplayer games, live chat — anything where the client is also a producer of real-time events.
Most dashboards, notification badges, order status pages, and "live" reports only need one direction: server telling the browser something changed. That's SSE territory, not WebSockets.
Start Here: Livewire Polling
I built a production scheduling dashboard for an industrial client a couple years ago. Operators needed to see machine queue status update without refreshing. My first instinct was Reverb or Pusher. I took a breath and asked: how fresh does this data actually need to be?
The answer was "within 30 seconds is fine." Polling it is.
// In your Livewire component
class MachineQueueDashboard extends Component
{
#[Poll(5000)] // Livewire v3 attribute — polls every 5 seconds
public array $queues = [];
public function mount(): void
{
$this->loadQueues();
}
public function loadQueues(): void
{
$this->queues = MachineQueue::with('currentJob')
->active()
->get()
->toArray();
}
public function render(): View
{
return view('livewire.machine-queue-dashboard');
}
}
That's it. No Redis. No queue worker changes. No separate Node process. The #[Poll] attribute in Livewire v3 is the cleanest version of this pattern I've used. You can also scope it to only poll when the browser tab is visible (#[Poll(5000, on: 'visible')]), which is a nice touch for conserving server load when users have twelve tabs open.
The honest downside: if you have 200 users on that page and poll every 5 seconds, that's 40 requests per second hitting your app. Fine for a Forge server running a modest app. A problem if you're underpowered or if the query is expensive. Cache the query result and you've mostly solved it.
When You Need Push: SSE
I integrated an order fulfillment tracker for an e-commerce client where the warehouse team needed to see new orders pop in as they were placed — no polling lag, but no need for the browser to send anything back either. SSE is exactly right here.
Laravel doesn't have first-class SSE support built in, but it's about 40 lines of PHP.
// routes/web.php
Route::get('/orders/stream', OrderStreamController::class)
->middleware('auth');
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class OrderStreamController extends Controller
{
public function __invoke(Request $request): StreamedResponse
{
return response()->stream(function () use ($request) {
$lastId = (int) $request->header('Last-Event-ID', 0);
while (true) {
if (connection_aborted()) {
break;
}
$orders = Order::where('id', '>', $lastId)
->latest()
->take(10)
->get();
foreach ($orders as $order) {
$lastId = $order->id;
echo "id: {$order->id}\n";
echo 'data: ' . json_encode([
'id' => $order->id,
'customer' => $order->customer_name,
'total' => $order->total_formatted,
'status' => $order->status,
]) . "\n\n";
ob_flush();
flush();
}
sleep(2);
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no', // critical if you're behind nginx
]);
}
}
On the frontend, no framework needed:
const source = new EventSource('/orders/stream');
source.addEventListener('message', (e) => {
const order = JSON.parse(e.data);
prependOrderRow(order); // your DOM update function
});
source.addEventListener('error', () => {
// EventSource reconnects automatically — you usually don't need to do anything
console.warn('SSE connection lost, browser will retry...');
});
The Last-Event-ID header is the killer feature here. When the browser reconnects (tab comes back, brief network blip), it sends the ID of the last event it received. Your server picks up exactly where it left off. No duplicate events, no missed events.
SSE Gotchas That Will Bite You
Nginx buffering. Without X-Accel-Buffering: no, nginx will buffer your stream and the client won't see anything until the buffer fills. This one cost me two hours the first time.
PHP-FPM connection limits. Each SSE stream holds a PHP-FPM worker open for its entire duration. If you have 100 concurrent users streaming and a pool of 50 workers, you will run out. The mitigation is to make the sleep interval longer (say, 3–5 seconds instead of 1) and to keep the query fast. For high-concurrency SSE, you'd want a non-blocking runtime (Octane with Swoole, or a dedicated Go/Node microservice for the stream endpoint) — but for dozens of users, plain FPM is fine.
Proxies and timeouts. Some corporate proxies kill HTTP connections that look idle. Emit a comment line (echo ": heartbeat\n\n"; flush();) every 20 seconds to keep the connection alive.
When You Actually Need WebSockets
I'm working right now with a healthcare client on a care coordination tool where clinicians on different machines are editing the same patient intake form simultaneously. That's collaborative. That's stateful. That needs WebSockets.
For this I'm using Laravel Reverb (released stable early 2024) with Laravel Echo on the frontend. Reverb is a first-party WebSocket server written in PHP — runs as a separate process, but it's in the same ecosystem, uses the same broadcasting config, and doesn't require a Node process or a third-party service.
// app/Events/IntakeFieldUpdated.php
class IntakeFieldUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public readonly int $patientId,
public readonly string $field,
public readonly mixed $value,
public readonly int $updatedBy,
) {}
public function broadcastOn(): array
{
return [
new PresenceChannel("intake.{$this->patientId}"),
];
}
}
// Echo on the frontend
Echo.join(`intake.${patientId}`)
.here((users) => { renderOnlineUsers(users); })
.joining((user) => { markUserOnline(user); })
.leaving((user) => { markUserOffline(user); })
.listen('IntakeFieldUpdated', (e) => {
if (e.updated_by !== currentUserId) {
applyRemoteFieldUpdate(e.field, e.value);
}
});
Presence channels give you the "who's in this room" feature for free. That's hard to replicate with SSE.
But here's the thing: this is genuinely complex. I'm running Reverb as a supervised process, thinking about reconnection states, handling optimistic UI conflicts, and testing edge cases around concurrent saves. It's the right call for collaborative editing. It would be massive overkill for a status dashboard.
My Decision Tree
When a client asks for real-time, I run through this:
- How stale can the data be? If 10–30 seconds is acceptable, polling is fine and I ship it in an hour.
- Is it one direction (server → client) or two? One direction: SSE. Two directions, or I need presence/rooms: WebSockets.
- How many concurrent users? Under ~50 concurrent streams, SSE on FPM is fine. More than that, I'm thinking about Octane or Reverb for the stream.
- Does the client already have a Pusher subscription or Ably? Then I'll use Laravel's built-in broadcasting with that driver rather than self-hosting Reverb. Why reinvent infrastructure they're already paying for?
The one thing I'd push back on hard: don't introduce a Node WebSocket server into a Laravel app just because the tutorial you found used one. You're now running two runtimes, two deployment pipelines, and two things to wake up at 2am for. Reverb or Pusher keep you in one world.
When I'd Skip All of This
If the requirement is "show the user a notification when their export is ready" — that's a job for a database-backed notification polled at mount time and a Livewire event, or even just a page refresh on redirect after the queued job completes. Not every async UX needs a persistent connection.
I've seen teams spend two weeks building a WebSocket layer for a feature that could have been solved with a redirect and a flash message. Real-time is a complexity multiplier. Reach for it when the UX genuinely requires it, not because it's cool.
Pick the dumbest thing that works. Polling is often that thing. SSE is the step up when push actually matters. WebSockets are for when you're building something genuinely collaborative. Defaulting to the full JS stack for anything that moves is how you end up maintaining a React app, a Node socket server, and a Laravel API when you could have had one Laravel app.
Need help shipping something like this? Get in touch.