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.targetbecomes the SSE event name; a target-lessFragmentemits an unnamed frame that the defaultsse-swap="message"listener receives.sse-swapmust be on a child ofsse-connect, never the same element.hx-disinherit="hx-target hx-swap"on thesse-connectelement stops a layout-levelhx-targetfrom bleeding into SSE swaps.hx-target="this"on eachsse-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.
Advanced: excluding the block and declaring derived paths
This wiring lives on the reactive bus index. Exclude a client-managed block so the reactive system never tries to swap it:
# Dependency index: editor block is NOT registered
index.register_from_sse_swaps(env, "page.html", source,
exclude_blocks={"editor_content"})
# Derived paths: version always changes when content changes,
# so version-dependent display blocks update even if the store
# only emits {"doc.content"}.
index.derive("doc.version", from_paths={"doc.content"})
index.derive(path, *, from_paths=...)declares a computed relationship between
context paths: when a source path changes, the derived path joins the affected set
automatically, so display blocks that depend on computed values update without extra
wiring. Full model: Reactive System.
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-swapis on the inner<span>, not thesse-connectdiv.hx-swap="beforeend"appends tokens instead of replacing them.sse-close="done"closes the connection when streaming finishes.- A yielded
Fragmentwith notargetemits 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:
- Restore
hx-targeton navigation links. Addhx-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 no
sse-swap. Elements you update from JavaScript (chat input, custom widgets) are invisible to the reactive system; putsse-swaponly on elements that receive server-pushed fragments. - Nested SSE puts
sse-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
DOM structure & layout overflow
Get the swap-target structure right to avoid redundant wrappers and horizontal overflow.
Outer element (layout container): holds padding, border, and flex/grid layout. It stays in place and is never swapped.
Inner element (swap target): carries theid that matches sse-swap. The
fragment block renders only the inner content, not the outer wrapper.
<!-- Outer: layout container (padding, border) -->
<div class="answer">
<!-- Inner: swap target — fragment content goes here -->
<div id="answer-body" class="answer-body" sse-swap="answer_body">
{% block answer_body %}
<div class="answer-content prose">{{ content }}</div>
{% endblock %}
</div>
</div>
Avoid nesting elements with the same class (e.g. .answer-with-copyinside
.answer) — that doubles the padding and border.
For flex or grid children that hold long content (code blocks, wide tables), add
min-width: 0 and overflow-x: autoso the container does not force horizontal page
overflow:
.answer-body { min-width: 0; overflow: hidden; }
.answer-content pre { overflow-x: auto; }
Operating the reactive bus (counters, queue depth)
TheReactiveBusexposes observability counters and a tunable per-subscriber queue
depth. These are operator concerns; the bus model lives on the
Reactive System page.
from chirp.pages.reactive import ReactiveBus
bus = ReactiveBus(maxsize=64) # default: 256
bus.emitted_count # total events emitted (including dropped)
bus.dropped_count # events lost to full subscriber queues
bus.subscriber_count # active subscribers across all scopes
A small maxsizegives tight back-pressure (low latency); a large one tolerates
bursts. When a subscriber's queue is full, events are dropped anddropped_count
increments — monitor it to detect slow consumers. Full reference:
Reactive System.