Server-Sent Events: Live Updates Without the WebSocket Stack

Dashboards, progress bars, and notification badges only need the server to push. Server-Sent Events do that over plain HTTP, with reconnects built in, and often remove the extra real-time server from your architecture.

Most real-time features are one-way

Sit behind the word "real-time" in most web projects and you'll find the same shape: the server pushes, the browser listens. A dashboard refreshing when new data lands. A progress bar during a long import. An order status moving from packed to shipped. A notification badge going up. Nobody types into the channel.

WebSockets are built for more than that: two-way conversation, high message rates, many channels. Taking them on means running a persistent-connection service (Reverb, Pusher, or a Go WS edge), wiring auth at connection time, and configuring proxies to allow upgrades. All reasonable, when you need it. All dead weight, when the client just needs to hear about changes.

SSE is HTTP doing one thing well

A Server-Sent Events response is an HTTP request whose answer never finishes. The server replies with Content-Type: text/event-stream and keeps writing small messages. The browser connects with EventSource, which handles reconnects automatically and can replay missed messages by sending Last-Event-ID on a fresh request.

Because it's ordinary HTTP, the tooling you already have applies. Sessions and cookies authenticate it like any route. Reverse proxies, load balancers, and TLS termination need no special config to let it through. Any language can write it, including shell scripts.

A minimal Laravel route:

Route::get('/orders/{order}/events', function (Order $order) {
    return response()->stream(function () use ($order): void {
        $sentAt = 0;
        while (true) {
            if ($order->fresh()->updated_at->timestamp > $sentAt) {
                $sentAt = now()->timestamp;
                echo 'event: order'.PHP_EOL
                    .'data: '.json_encode(['status' => $order->fresh()->status]).PHP_EOL
                    .PHP_EOL;
            } else {
                echo ': keepalive'.PHP_EOL.PHP_EOL;
            }
            @ob_flush();
            flush();
            sleep(2);
        }
    }, 200, [
        'Content-Type' => 'text/event-stream',
        'Cache-Control' => 'no-cache',
        'X-Accel-Buffering' => 'no',
    ]);
});

The client is three lines:

const es = new EventSource('/orders/42/events');
es.addEventListener('order', (e) => updateStatus(JSON.parse(e.data)));

The sharp edges

  • Buffering. Any layer between PHP and the browser may hold output until the response ends. The X-Accel-Buffering: no header covers nginx; response compression (gzip) buffers too, so exclude event-stream responses.
  • Idle timeouts. Proxies and firewalls close quiet connections. The : keepalive comment every few seconds keeps them open; it's ignored by the client but counts as traffic.
  • PHP-FPM worker math. One open SSE stream holds one worker for its whole lifetime. Ten streams is nothing; a thousand hold a thousand workers. For small user counts per stream, or short-lived streams (import progress), FPM is fine. For thousands of long-lived connections, run the stream edge on Octane or Go, which is precisely where the WebSocket-stack costs you were avoiding would return anyway.
  • No upward channel. The client can't send messages through the stream. It doesn't need to: a regular fetch POST works like any other endpoint.
  • Browser connection caps. HTTP/1.1 allows six concurrent connections per host, and each stream eats one. With HTTP/2 (which most hosts run now) the streams multiplex over one connection and this stops mattering.

Choosing in one line

Two-way interaction or fast bursts of messages: WebSocket setup, done properly. Server-to-client updates with second-level freshness and a modest number of concurrent viewers: SSE, over the HTTP stack you already run. Freshness measured in tens of seconds: a setInterval poll is honest engineering, and half the dashboards doing WebSockets would be simpler and just as good with it.