SSE Patterns

Four real-time update patterns, each mapped to the Chirp return type that drives it

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

Overview

Real-time pages rarely use a single update strategy. A collaborative editor has a status badge the server owns, an editing surface the browser owns, an AI reply that streams token by token, and save/delete actions that fire once and finish. Each of those is a different pattern with a different return type.

This guide names the four patterns, gives the smallest working version of each, and tells you which return type to reach for. Start with the decision table, then jump to the pattern you need.

If you are new to the SSE wire format (sse-connect, sse-swap, event names), read SSE basics and the wire format first — this page assumes it. For a single value bound in many places, see signals.

Which pattern?

Pattern Use for Server owns rendering? Return type
1 — Display-only reactive Status badges, counters, presence lists, dashboards Yes — client is a passive display Fragment(over SSE)
2 — Client-managed surface contenteditable, canvas, drag-and-drop, code editors No — the browser owns the DOM dict(JSON)
3 — Streaming append AI chat tokens, live logs, activity feeds Yes — incremental fragments append EventStream + Fragment
4 — One-shot mutation Form submit, delete, rename One response, then done Action / Fragment / OOB

Pattern 1: Display-only reactive

Use for status badges, counters, presence lists, dashboards — any element where the server is the sole rendering authority and the client is a passive display.

# Server: yield Fragment with target matching the sse-swap attribute
async def stream():
    async for change in bus.subscribe(scope):
        yield Fragment("page.html", "status_block",
                       target="status", stats=get_stats())
<!-- Client: sse-swap on a CHILD of sse-connect -->
<div hx-ext="sse"
     sse-connect="/stream"
     hx-disinherit="hx-target hx-swap">
  <span id="status" sse-swap="status" hx-target="this">
    {% block status_block %}v{{ stats.version }}{% endblock %}
  </span>
</div>

The rules that make this work:

  • Fragment.target becomes the SSE event name; a target-less Fragmentemits an unnamed frame that the defaultsse-swap="message"listener receives.
  • sse-swap must be on a child of sse-connect, never the same element.
  • hx-disinherit="hx-target hx-swap" on the sse-connectelement stops a layout-levelhx-targetfrom bleeding into SSE swaps.
  • hx-target="this" on each sse-swapelement targets the swap correctly once inheritance is broken.

chirp check myapp:appvalidates all four rules at startup. See Compile-time validation below.

Pattern 2: Client-managed surfaces

Use forcontenteditableeditors, canvas drawing, drag-and-drop, code editors — any element where the browser owns the DOM tree.

The browser maintains internal state (cursor position, undo history, selection, paragraph elements) that cannot survive aninnerHTMLreplacement. So the server returns JSON, not rendered HTML, and the block is not registered in the reactive dependency index.

# Server: return JSON, not rendered HTML
async def post(doc_id: str, request: Request) -> dict:
    edit = parse_edit(await request.json())
    updated = store.apply_edit(edit)
    return {"ok": True, "version": updated.version}
<!-- Client: no sse-swap, no reactive rendering -->
<div class="editor"
     id="editor"
     contenteditable="true"
     data-doc-id="{{ doc.id }}"
     data-version="{{ doc.version }}"
>{{ doc.content }}</div>

For complex widgets that need framework adapters (React, Svelte, Vue) or custom logic, use Chirp islands. Islands provide adata-islandmount contract with lifecycle events and optional dynamic adapter loading. Islands are client-owned surfaces; the server does not swap HTML inside them.

For multi-user collaboration, send OT/CRDT operations over SSE as JSON (via SSEEvent) and apply them client-side. Do not re-render HTML.

Pattern 3: Streaming append

Use for AI chat tokens, live logs, activity feeds — content that arrives incrementally and appends to a container.

This pattern has two phases: a POST that returns the scaffolding, and an SSE stream that fills it in.

async def post(doc_id: str, request: Request) -> Fragment:
    form = await request.form()
    message = form["message"]
    return Fragment("_chat.html", "chat_start",
                    doc_id=doc_id, user_content=message)
def get(doc_id: str) -> EventStream:
    async def generate():
        async for token in ai_session.stream_reply():
            yield Fragment("_chat.html", "chat_token", token=token)
        yield SSEEvent(event="done", data="complete")
    return EventStream(generate())

The matching templates:

{# Phase 1: POST response — user bubble + AI bubble with SSE #}
{% block chat_start %}
<div class="msg msg-user">{{ user_content }}</div>
<div class="msg msg-ai"
     hx-ext="sse"
     sse-connect="/doc/{{ doc_id }}/chat/stream"
     sse-close="done">
  <span class="tokens" sse-swap="message" hx-swap="beforeend"></span>
  <span class="typing-cursor"></span>
</div>
{% endblock %}
{# Phase 2: each token #}
{%- block chat_token -%}
{%- if token is defined %}{{ token }}{% end -%}
{%- end -%}

The rules that make this work:

  • sse-swap is on the inner <span>, not the sse-connectdiv.
  • hx-swap="beforeend"appends tokens instead of replacing them.
  • sse-close="done"closes the connection when streaming finishes.
  • A yieldedFragment with no targetemits an unnamed SSE frame, so the default htmxsse-swap="message"listener receives it.

Pattern 4: One-shot mutations

Use for form submissions, delete buttons, rename actions — requests that produce a single response and are done.

async def post(doc_id: str, request: Request) -> Action:
    store.rename(doc_id, title=(await request.form())["title"])
    return Action(trigger="renamed")

Pick the return type by what the response should do:

Return type Behavior
Action() 204 No Content— side effect only, no swap
Action(trigger="event") 204 + HX-Triggerheader
Fragment(...) Render a block, swap into the target
OOB(main, *oob) Primary swap + out-of-band fragment swaps
ValidationError(...) 422+ re-rendered form with errors

Mixing patterns on one page

Most real pages combine patterns. The key principle: establish scope boundaries so patterns don't interfere with each other.

<body hx-boost="true" hx-target="#app-content">
  <nav>...</nav>
  <main id="app-content">
    <!-- SSE scope boundary: hx-disinherit prevents layout-level
         hx-target from reaching SSE swaps -->
    <div hx-ext="sse"
         sse-connect="/doc/{{ doc.id }}/stream"
         hx-disinherit="hx-target hx-swap">

      <!-- Pattern 1: display-only reactive -->
      <span id="status" sse-swap="status" hx-target="this">v{{ doc.version }}</span>
      <span id="title" sse-swap="title" hx-target="this">{{ doc.title }}</span>

      <!-- Pattern 2: client-managed (no sse-swap) -->
      <div id="editor" contenteditable="true">{{ doc.content }}</div>

      <!-- Pattern 4: one-shot mutation (explicit hx-target) -->
      <div class="toolbar" hx-target="#app-content">
        <a href="/documents" hx-push-url="true">Back</a>
      </div>

      <!-- Pattern 3: streaming append (nested SSE) -->
      <div id="chat">
        <form hx-post="/doc/{{ doc.id }}/chat"
              hx-target="#chat-messages"
              hx-swap="beforeend">
          <input name="message">
          <button>Send</button>
        </form>
        <div id="chat-messages"></div>
      </div>
    </div>
  </main>
</body>

The rules for mixing:

  • Restorehx-target on navigation links. Add hx-target="#app-content"on toolbar and nav containers inside the SSE scope. Once inheritance is broken, nav links need an explicit target.
  • Client-managed elements get nosse-swap. Elements you update from JavaScript (chat input, custom widgets) are invisible to the reactive system; putsse-swap only on elements that receive server-pushed fragments.
  • Nested SSE putssse-swapon a child. The connect element establishes the connection; the swap element receives the fragments — never the same element.

Multi-swap (RAG-style)

When one SSE stream updates multiple regions (sources, answer, share link), use multiplesse-swap elements inside a single sse-connect:

<article hx-ext="sse"
         sse-connect="{{ stream_url }}"
         sse-close="done"
         hx-disinherit="hx-target hx-swap">
  <div class="question-block">...</div>
  <div class="sources" sse-swap="sources" hx-target="this">...</div>
  <div class="answer-section">
    <span class="answer-label">Answer</span>
    <div class="answer" sse-swap="answer" hx-target="this">...</div>
    <div class="share-link-wrap" sse-swap="share_link" hx-target="this"></div>
  </div>
</article>

The same two rules from Pattern 1 apply to each region: hx-disinherit="hx-target hx-swap" on the sse-connectelement isolates every swap from layout inheritance, andhx-target="this" on each sse-swapelement targets it correctly. See RAG demo for the full implementation.

Reconnect and replay

SSE gives the browser reconnect mechanics, not durable product semantics. When you yieldSSEEvent(id=...), the browser sends the latest id back as Last-Event-ID after reconnect. Chirp exposes that header on the request and formats theid:line, but the product owns the durable cursor and the missed-event query.

Use domain cursors you can query later: notification ids, post ids, database sequence numbers, or queue offsets. Avoid process-local counters or random ids for product-critical streams.

async def stream(request):
    last_id = request.headers.get("last-event-id")

    async def events():
        async for item in missed_items_after(last_id):
            yield SSEEvent(event="item", id=str(item.id), data=item.html)
        async for item in live_items():
            yield SSEEvent(event="item", id=str(item.id), data=item.html)

    return EventStream(events())

When replay is impossible, send a refresh event for the affected fragment so reconnecting clients reload it.

Compile-time validation

chirp checkcatches common SSE mistakes at startup, before they reach the browser:

Check Severity What it catches
sse_self_swap ERROR sse-swap on the same element as sse-connect
sse_scope ERROR sse-connect inside broad hx-targetwithout mitigation
swap_safety WARNING sse-swap element inheriting a broad hx-target
swap_safety INFO sse-swap without hx-target="this"(suggests adding it)
select_inheritance WARNING Mutating element may inherit a broadhx-selectfrom a layout ancestor, silently discarding fragment responses (see Layout Patterns)

Runchirp check myapp:appduring development to catch these before runtime.

Advanced

See also