HTMX Server-Sent Events

Server-Sent Events (SSE) is a browser technology where the server pushes data to the client over a persistent connection. Unlike polling — where the client asks the server repeatedly — SSE keeps one connection open and the server sends data whenever something changes. HTMX has built-in support for SSE through an official extension, making real-time updates easy to wire up with HTML attributes.

SSE vs Polling: The Key Difference

  POLLING:
  Client ──── request ────► Server   (every 5 seconds)
  Client ◄─── response ─── Server
  Client ──── request ────► Server   (5 seconds later)
  Client ◄─── response ─── Server
  ...repeated forever...
  Problem: Wasteful when nothing has changed

  SERVER-SENT EVENTS:
  Client ──── open connection ────► Server
  Server ◄─────────────────────── (connection held open)
  Server ──── event: score ──────► Client   (when goal is scored)
  Server ──── event: score ──────► Client   (when another goal is scored)
  Server ──── event: score ──────► Client   ...
  Advantage: Server sends data ONLY when something changes

Setting Up the SSE Extension

SSE support is not included in the core HTMX library. Load the official SSE extension after the main HTMX script:

<script src="https://unpkg.com/htmx.org@2.0.0"></script>
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>

Client-Side: Connecting to an SSE Endpoint

<div
  hx-ext="sse"
  sse-connect="/events/live-feed"
  sse-swap="message"
  hx-target="this"
  hx-swap="beforeend">
  Connecting to live feed...
</div>
AttributePurpose
hx-ext="sse"Activates the SSE extension on this element
sse-connect="/url"The server endpoint to open the SSE connection to
sse-swap="message"The SSE event name to listen for (default: "message")
hx-swapHow to insert each incoming event's HTML

Server-Side: Sending SSE

The server endpoint must respond with the content type text/event-stream and keep the connection open, sending events as they occur.

Flask (Python)

import time
from flask import Response, stream_with_context

@app.route('/events/live-feed')
def live_feed():
    def generate():
        while True:
            # Fetch the latest data
            score = get_current_score()
            # Format as SSE
            yield f"data: <li>Score: {score}</li>\n\n"
            time.sleep(3)

    return Response(
        stream_with_context(generate()),
        content_type='text/event-stream',
        headers={
            'Cache-Control': 'no-cache',
            'X-Accel-Buffering': 'no'   # Important for Nginx
        }
    )

Node.js (Express)

app.get('/events/live-feed', (req, res) => {
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');

    const interval = setInterval(() => {
        const score = getCurrentScore();
        res.write(`data: <li>Score: ${score}</li>\n\n`);
    }, 3000);

    req.on('close', () => clearInterval(interval));
});

SSE Event Format

The SSE protocol sends plain text over the connection. Each event follows a specific format:

  event: score          ← optional event name (default: "message")
  data: <li>2–1</li>  ← the HTML fragment to send
                        ← blank line = end of this event

When you specify a custom event name (like score), update sse-swap on the client to match:

<div
  hx-ext="sse"
  sse-connect="/events/match"
  sse-swap="score"
  hx-target="#scoreboard"
  hx-swap="innerHTML">
</div>

<div id="scoreboard">Waiting for kickoff...</div>

Listening to Multiple Event Types

A single SSE connection can send different event types. Use multiple child elements inside the SSE container, each with its own sse-swap:

<div hx-ext="sse" sse-connect="/events/dashboard">

  <!-- Listens for "cpu" events -->
  <div sse-swap="cpu" hx-target="#cpu-gauge" hx-swap="innerHTML"></div>

  <!-- Listens for "memory" events -->
  <div sse-swap="memory" hx-target="#mem-gauge" hx-swap="innerHTML"></div>

  <!-- Listens for "alert" events -->
  <div sse-swap="alert" hx-target="#alert-panel" hx-swap="beforeend"></div>
</div>

<div id="cpu-gauge">CPU: --</div>
<div id="mem-gauge">RAM: --</div>
<div id="alert-panel"></div>
  Flow:

  One SSE connection to /events/dashboard

  Server sends: event: cpu\ndata: <p>CPU: 43%</p>\n\n
    → #cpu-gauge updates to "CPU: 43%"

  Server sends: event: memory\ndata: <p>RAM: 71%</p>\n\n
    → #mem-gauge updates to "RAM: 71%"

  Server sends: event: alert\ndata: <p>High disk I/O!</p>\n\n
    → #alert-panel gets "High disk I/O!" appended

Automatic Reconnection

The browser automatically reconnects if the SSE connection drops. The SSE protocol includes a retry field that sets the reconnection delay in milliseconds:

def generate():
    yield "retry: 5000\n\n"   # Reconnect after 5 seconds if connection drops
    while True:
        data = get_update()
        yield f"data: {data}\n\n"
        time.sleep(1)

SSE Connection Limits

Browsers allow a maximum of 6 simultaneous connections to the same origin. Since each SSE connection holds one of those slots open, a page with multiple SSE connections can run into this limit quickly. The solution is to multiplex all updates through a single SSE connection and differentiate them by event name, as shown in the multi-event example above.

When to Choose SSE Over Polling

  • Updates are infrequent or unpredictable — SSE sends data only when it is ready.
  • You want to eliminate wasted requests that return unchanged data.
  • Updates need to feel instant — SSE delivers within milliseconds of the server event.
  • You are building a notification system, a live feed, or a job progress tracker.

Key Takeaway

Server-Sent Events keep a single persistent connection open between the browser and the server. The server pushes HTML fragments through that connection whenever data changes. HTMX's SSE extension listens for events by name and swaps the incoming HTML into the specified target. SSE is more efficient than polling for infrequent or unpredictable updates because the server sends data only when it exists — no wasted requests for unchanged data.

Leave a Comment

Your email address will not be published. Required fields are marked *