SSE live updates
sse-updates is the blessed pattern for server-pushed UI: a feed,
status panel, or datagrid that re-renders because the server said so,
not because the user acted. The
htmx SSE extension
owns the EventSource; the markup declares the stream and the event
names; the server sends named events whose data is a finished
fragment — the same markup-as-wire-contract stance as every other
recipe. It is stable under the
markup versioning policy.
No Hypermedia Components behavior is needed for fragment pushes — for server-pushed notifications and domain events, see the SSE toast recipe.
Also known as: real-time updates, live push.
Live demo
Section titled “Live demo”The stream plays a ~25 s scripted sequence once, then ends itself
deliberately with stream:done: timestamped activity items prepend
into the feed, the status panel re-renders, one push also updates
the alert badge out-of-band in the same event, and two products:rows
pushes swap a full page of rows into the datagrid’s tbody (the
composition below). The sequence starts when
the page loads — if it already finished, Replay the stream swaps
a fresh scope in (a new EventSource) and runs it again.
Waiting for updates…
Alerts:0
| ID | Status | Updated |
|---|---|---|
| — | Waiting for pushed rows… | — |
The markup
Section titled “The markup”<div data-hx-ext="sse" data-sse-connect="/events" data-sse-close="stream:done"> <ul id="activity" data-sse-swap="activity:item" data-hx-swap="afterbegin"> <li class="hc-item">…server-rendered current items…</li> </ul>
<section id="status" data-sse-swap="status:panel"> …server-rendered current status… </section></div>One data-sse-connect scope per stream. Each element inside names the
events it receives with data-sse-swap; the event’s data is swapped in
honouring the element’s data-hx-swap (innerHTML by default,
afterbegin for feeds). The extension supports the data- prefixed
attribute forms, matching the kit’s data-hx-* convention.
The stream
Section titled “The stream”retry: 5000
event: activity:itemdata: <li class="hc-item">Deploy #42 started</li>
event: status:paneldata: <p>All systems normal</p><span id="alert-badge" data-hx-swap-oob="true">3</span>Name events by domain (activity:item, status:panel) — the names are
wire contract. A message’s data may also carry data-hx-swap-oob fragments
that update other targets by id in the same push (the second example
updates both the panel and a badge), exactly like the
datagrid-pager and
bulk-actions responses.
Server response contract
Section titled “Server response contract”GET /events answers Content-Type: text/event-stream; each update
is a named event whose data: is a finished, server-rendered
fragment on one line. There is no HX-Request branch — EventSource
requests carry no htmx headers, so the stream is the endpoint’s only
shape.
| Request | Response |
|---|---|
GET /events | 200 + Content-Type: text/event-stream — named events (event: activity:item), one finished fragment per data: line |
| Reconnect after a drop | The same stream — EventSource retries natively (honouring the server’s retry: hint); resend current state, or tolerate the gap |
| Deliberate end | Send the event named by data-sse-close (here stream:done) — a closed stream stays closed, and the close event’s own payload is never swapped |
| Cross-origin stream | The extension connects withCredentials: true, so the server needs Access-Control-Allow-Credentials |
Datagrid composition
Section titled “Datagrid composition”Push a page of rows by putting data-sse-swap +
data-hx-swap="innerHTML" on the .hc-datagrid__body tbody. The
keep-the-tbody rule from the pager applies unchanged: the grid’s
observer survives the swap, re-applies roles and sticky offsets, and
re-derives selection — so a
selection actions bar
clears itself when pushed rows arrive, with no extra wiring.
Lifecycle
Section titled “Lifecycle”Reconnection is htmx-native: EventSource retries automatically
(honouring the server’s retry: hint) and the extension adds backoff
on repeated failures — so the server must expect reconnects and resend
current state, or tolerate the gap. End streams deliberately with
data-sse-close="<event>" (a dropped stream reconnects; a closed one
stays closed). htmx:sseOpen / htmx:sseError / htmx:sseClose fire
on the scope for debugging. The extension connects with
withCredentials: true — cookies ride along.
No-JS degradation
Section titled “No-JS degradation”Render the complete current state into the initial HTML; the stream only freshens it. Without JS the page is simply as fresh as the last full render — no loading shells, nothing breaks.
The claims here are pinned by a real-EventSource browser test
(test-browser/sse.spec.mjs).
Accessibility
Section titled “Accessibility”- Pushed fragments replace content without moving focus — never wrap a live region around the element the user is currently focused on.
- Announcements are opt-in: put
aria-live="polite"on the region the fragments land in. A status line usually should announce; a feed usually should not read out every item. - For server-pushed notifications, prefer the
SSE toast recipe —
toasts already carry the correct
role="status"/role="alert". - Pushed datagrid rows go through the grid’s normal re-init, so roles and the keyboard model survive every swap.
Related
Section titled “Related”- SSE toast recipe — server-pushed notifications and domain events via the dispatch bridge.
- htmx integration — Server-sent events — loading the extension, lifecycle, credentials.
- Datagrid pager recipe — the tbody swap rules this composes with.
- Data region recipe — pull-based refresh, the complementary pattern.