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>
| Attribute | Purpose |
|---|---|
| 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-swap | How 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.
