Server-Sent Events: Live Updates Without Polling in Alpine.js Components
AI generated
x-data
Alpine
Alpine.js / Real-Time
Server-Sent Events: Live Updates Without Polling
How an EventSource connection streams server push data directly into an Alpine component

Repeatedly asking the server via setInterval is the most obvious way to keep an Alpine component current, but it is rarely the most efficient one. Server-Sent Events offer a lean alternative: through the native EventSource API, the browser opens a single, long-lived HTTP connection, and the server pushes new data exactly when something actually changes instead of waiting for a request. This article shows how to initialize such a connection cleanly in x-data, how incoming events get translated into reactive Alpine state, how to reliably handle dropped connections, and where the technical limits of Server-Sent Events sit compared to polling and WebSocket.

10 min read EventSource Reconnect Handling

1. Why repeated polling hits a wall

A five-second polling interval sounds like a harmless decision, but with a thousand simultaneously open tabs it produces twelve thousand requests per minute, even though the underlying value often does not change for minutes at a time. Every one of those requests runs through the full HTTP stack, including a TLS handshake if the connection is not reused, routing, application logic, and a database query, even when the response ends up identical to the previous one. That is compute spent on no actual change at all.

On top of that comes a staleness problem: between two polling cycles there is always a window where the client shows outdated data without knowing it. For a stock level that changes every second during a sale event, a five-second interval means the display can be almost five seconds stale in the worst case, while at the same time generating unnecessary load whenever nothing changed. Server-Sent Events solve exactly this dilemma by handing the initiative to the server.

2. What Server-Sent Events actually are, technically

Server-Sent Events rely on a single, long-held HTTP connection with a response Content-Type of text/event-stream. The server keeps writing text blocks into that stream, each with a data field and optionally an event name and an id, and the connection stays open until either the client or the server actively ends it. Unlike WebSocket, there is no separate protocol handshake with an upgrade to ws://, SSE runs entirely over plain HTTP and therefore passes through most existing HTTP infrastructure, such as load balancers, without special handling in most cases.

The key difference from polling is the direction of communication: with SSE, only the server initiates data transfer, the client opens the connection once and then passively receives whatever arrives. That not only saves repeated requests, it also brings automatic reconnect behavior baked directly into the specification, so it does not have to be hand-built the way it would for a custom WebSocket solution.

3. Initializing an EventSource connection in x-data

In an Alpine component, the EventSource initialization belongs in the init() method, so the connection is established exactly when the element gets mounted into the DOM, not merely when the x-data expression is evaluated. The EventSource constructor takes the stream URL and opens the connection automatically, with no extra connect() call needed.

It matters to store the instance on this, so it stays reachable later in the $destroy hook. Without that reference, the connection remains open even after the component has long been removed from the DOM, which over time leads to a growing number of orphaned connections in the browser.


document.addEventListener('alpine:init', () => {
  Alpine.data('stockWidget', (productSku) => ({
    stock: null,
    connected: false,
    lastUpdate: null,

    init() {
      this.source = new EventSource(`/sse/stock/${productSku}`);

      this.source.onopen = () => {
        this.connected = true;
      };

      this.source.onmessage = (event) => {
        this.applyUpdate(event.data);
      };

      this.source.onerror = () => {
        // readyState 0 = CONNECTING (browser is already retrying on its own)
        // readyState 2 = CLOSED (connection ended for good)
        this.connected = this.source.readyState !== EventSource.CLOSED;
      };
    },

    applyUpdate(rawData) {
      const payload = JSON.parse(rawData);
      this.stock = payload.quantity;
      this.lastUpdate = new Date();
    },

    destroy() {
      this.source?.close();
    },
  }));
});

4. Parsing incoming events and updating reactive state

By default, every data block in the stream fires the generic message event, caught with onmessage. As soon as a stream carries more than one kind of event, say stock changes and price changes over the same channel, it pays off to switch to named events with addEventListener, since handling can then be cleanly separated per event type instead of manually inspecting a type field on the raw payload.

Because EventSource only transports text, any non-trivial payload has to be serialized as JSON on the server and parsed back into an object with JSON.parse on the client. Reactivity itself is then handled by Alpine automatically: as soon as a property such as this.stock gets reassigned inside the component, every bound x-text or x-show in the template updates without any further work.


init() {
  this.source = new EventSource(`/sse/stock/${this.productSku}`);

  this.source.addEventListener('stock-changed', (event) => {
    const payload = JSON.parse(event.data);
    this.stock = payload.quantity;
    this.lowStock = payload.quantity > 0 && payload.quantity <= 5;
  });

  this.source.addEventListener('price-changed', (event) => {
    const payload = JSON.parse(event.data);
    this.price = payload.formattedPrice;
    this.$dispatch('price-updated', { sku: this.productSku });
  });
}

5. Reconnect behavior: what the browser handles automatically and what it does not

One major practical advantage of Server-Sent Events is that the browser retries the connection on its own after a drop, by default after about three seconds. The server can influence that interval through a retry line in the stream, and through the Last-Event-ID header the client automatically reports which event it last received on every reconnect, so a properly implemented server can resume seamlessly right at that point.

That automatic behavior does not kick in for every case, though: if the server explicitly calls source.close() on the client, or returns an HTTP status code outside the success range, readyState switches permanently to CLOSED and the browser stops retrying altogether. For that scenario it is worth building a dedicated recovery mechanism into the component that periodically checks readyState and, if needed, creates a brand new EventSource instance with exponentially increasing wait time, instead of quietly leaving the interface without live data.


reconnectWithBackoff(attempt = 1) {
  const delay = Math.min(1000 * 2 ** attempt, 30000);

  setTimeout(() => {
    if (this.source?.readyState === EventSource.CLOSED) {
      this.source = new EventSource(`/sse/stock/${this.productSku}`);
      this.bindHandlers();
      this.source.onerror = () => this.reconnectWithBackoff(attempt + 1);
    }
  }, delay);
}

6. Server-side requirements for an SSE stream

The server has to deliver the response with the Content-Type: text/event-stream header, disable output buffering, and explicitly flush after every written block, so data arrives at the client immediately instead of only at the end of the PHP script. In a Symfony application, a StreamedResponse handles exactly that job, with the callback looping, checking for new data, and emitting an SSE-formatted block whenever needed.

It is also worth sending a regular comment heartbeat, a line starting with a colon that SSE clients ignore, so reverse proxies like Nginx do not cut the connection after a timeout due to perceived inactivity. On Nginx, the X-Accel-Buffering header also needs to be set to no, otherwise the proxy buffers the entire response and only delivers it once the connection ends, which defeats the whole point of Server-Sent Events.


<?php
declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;

final class StockStreamController
{
    #[Route('/sse/stock/{sku}', name: 'stock_stream')]
    public function stream(string $sku, StockChangeReader $reader): StreamedResponse
    {
        $response = new StreamedResponse(function () use ($sku, $reader): void {
            while (true) {
                if (connection_aborted()) {
                    break;
                }

                $change = $reader->pollNextChange($sku);

                if ($change !== null) {
                    echo "event: stock-changed\n";
                    echo 'data: ' . json_encode($change) . "\n\n";
                } else {
                    echo ": heartbeat\n\n";
                }

                flush();
                sleep(2);
            }
        });

        $response->headers->set('Content-Type', 'text/event-stream');
        $response->headers->set('Cache-Control', 'no-cache');
        $response->headers->set('X-Accel-Buffering', 'no');

        return $response;
    }
}

7. Resource usage and freshness compared to polling

The resource advantage of SSE comes from connection setup and HTTP overhead happening only once, while polling repeats that overhead on every single cycle. Over HTTP/2, multiple SSE streams can also be multiplexed over a single underlying TCP connection, which makes the classic six-connections-per-domain limit from HTTP/1.1 largely irrelevant in practice.

In terms of freshness the difference is fundamental: polling shows a change at the earliest on the next cycle, on average after half the interval, while an SSE push happens near real time as soon as the server writes the event. In practice that means noticeably lower latency alongside lower server load, at the cost of the server now having to hold a permanently open connection per client, which requires its own scaling considerations at very high concurrent user counts.

8. Where Server-Sent Events hit their limits

Server-Sent Events are deliberately unidirectional: the client cannot send data back over the same channel, a response requires a separate HTTP request. For pure display updates like stock level or status, that is not a drawback, but for interactive scenarios with frequent client-to-server traffic, WebSocket quickly becomes the better fit. SSE also only transports text, binary data has to be base64-encoded first, which noticeably increases payload size.

Without HTTP/2, the browser's six-connections-per-domain limit still applies when many tabs of the same domain are open at once, which can block further tabs until a connection frees up. Some restrictively configured corporate proxies also buffer or terminate long-running HTTP connections after a fixed timeout, occasionally causing unexpected reconnects on corporate networks that never show up in a local test setup without such a proxy.

9. Practical example: a stock level widget with live updates

In a Hyvä theme, such a widget can sit as a self-contained Alpine component on the product detail page, opening its own SSE channel independently of the rest of the server-rendered markup. If the stock level drops below a critical threshold during a sale event, the widget immediately shows a warning like 'Only 3 left', without the page reloading or an interval timer having to elapse first.

Combined with the reconnect mechanism shown above, the widget stays functional even through brief network outages and, in the meantime, clearly displays a 'live update paused' notice instead of silently presenting stale numbers. That transparency about connection status is exactly what separates a robust SSE widget from a naive implementation that simply ignores connection problems.

Aspect Polling Server-Sent Events WebSocket
Connection type Repeated individual HTTP requests One permanently open HTTP connection Its own protocol after an HTTP upgrade
Direction Only the client actively asks Only the server sends to the client Bidirectional in both directions
Freshness Depends on the interval Near instant on a server event Near instant, in both directions
Reconnect Not a special case, every request is new Built natively into the browser Has to be implemented manually
Typical use Rare changes, simple logic Status changes, stock levels, feeds Chat, collaborative editing

Mironsoft

Alpine.js interactivity for Hyvä frontends

A Hyvä frontend that needs more interactivity, but without React overhead?

We build interactive frontend components for Hyvä themes with Alpine.js, lightweight and without build-step complexity, from simple toggles to complex form flows.

Custom Components

Develop interactive Alpine.js components for specific shop requirements.

Performance Review

Review existing Alpine.js implementations for reactivity pitfalls and performance.

Team Training

Bring developers up to speed on Alpine.js patterns for Hyvä themes hands-on.

10. Summary

Server-Sent Events with Alpine.js

Core idea

An EventSource connection lets the server actively push changes instead of the client repeatedly asking.

When to use it

For display updates without a return channel, such as stock levels, status, or notifications.

Server-side requirement

A streamed response with buffering disabled, correct headers, and a regular heartbeat.

Biggest limitation

Purely unidirectional and text-based, client-to-server traffic needs a separate channel.

11. FAQ: Server-Sent Events with Alpine.js

1What is the difference between Server-Sent Events and WebSocket?
Server-Sent Events are unidirectional and run over plain HTTP, WebSocket is bidirectional and uses its own protocol after an upgrade. SSE is enough for pure server-to-client updates, WebSocket is needed for genuine two-way traffic.
2Do I have to implement reconnect for Server-Sent Events myself?
For most cases no, the browser reconnects automatically after a dropped connection. Only when the connection is explicitly closed or readyState switches permanently to CLOSED does it need its own recovery logic.
3Can I also send data to the server through Server-Sent Events?
No, the EventSource channel is purely for server-to-client data. A request in the other direction needs a separate HTTP request or a different channel such as WebSocket.
4Why do my SSE messages arrive delayed at the client?
Usually it is missing flushing on the server side or a reverse proxy buffering the response. On Nginx, X-Accel-Buffering has to be set to no so data gets passed through immediately.
5Do Server-Sent Events work in all browsers?
All modern browsers support the EventSource API natively, older versions of Internet Explorer do not. For production projects with modern browser support that is barely relevant anymore in practice.
6How do I know in Alpine.js whether the SSE connection is still active?
Through the readyState property of the EventSource instance, which distinguishes between CONNECTING, OPEN, and CLOSED. That value should be mirrored into a reactive Alpine property to display connection status in the template.
7How many concurrent SSE connections can a server handle?
That depends on the application server and process model in use. Classic PHP-FPM with blocking worker processes scales worse for many long-lived connections than asynchronous runtimes, so this aspect should be tested before going into production.
8Can I send multiple event types over a single SSE stream?
Yes, through named events using the event field in the stream and addEventListener on the client side, an arbitrary number of event types can be handled cleanly separated over a single connection.
9Should I use Server-Sent Events or polling for a stock level widget?
With frequent changes and many concurrent viewers, for example during a sale event, SSE is usually more efficient. With rare changes and low traffic, simple polling with a larger interval can be sufficient and simpler to operate.
10How do I cleanly close an EventSource connection in Alpine.js?
The instance is stored on this in the init() hook and closed with source.close() in the component's destroy() hook, so no open connection is left behind once the component is removed from the DOM.