Server-Sent Events

Push real-time HTML updates to the browser

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

Return anEventStreamfrom a handler and Chirp holds the HTTP connection open, formatting whatever your async generator yields as SSE wire events. Yield a Fragment and the browser swaps rendered HTML into the page in real time — no client-side render code.

Reach forEventStreamwhen the page needs updates after it loads: notifications, a live ticker, a dashboard tail. For a one-shot slow page that should paint progressively on first load, use Stream or Suspense instead.

EventStream

Return anEventStreamfrom a route handler to start pushing events:

from chirp import EventStream

@app.route("/events", referenced=True)
async def events():
    async def stream():
        while True:
            data = await get_next_update()
            yield data
    return EventStream(stream())

The generator yields values. Chirp formats them as SSE wire protocol and sends them to the client. Mark dynamically referenced SSE routes with referenced=True; static sse-connect and hx-sse:connectURLs are discovered byapp.check().

Yield Types

The generator can yield different types:

from chirp import Fragment, SSEEvent

async def stream():
    # String -- sent as the SSE data field
    yield "Hello, World!"

    # Dict -- JSON-serialized as the SSE data field
    yield {"count": 42, "status": "ok"}

    # Fragment -- rendered via Kida from the same named block.
    # htmx 2 treats target= as a named SSE channel. The managed htmx 4
    # client treats target= as a DOM id in an unnamed <hx-partial> frame.
    yield Fragment("components/notification.html", "alert",
                   target="notification", message="New alert")

    # SSEEvent -- full control over event type, id, retry
    yield SSEEvent(data="custom", event="ping", id="1")

Yielding a Fragmentcovers the common case. The rendered HTML is always the data; only its client envelope changes. Htmx 2 mapstargetto a named sse-swapchannel. The exact htmx 4 preview sends unnamed HTML and maps an explicittarget to a validated <hx-partial hx-target="#...">. Most apps constructSSEEventonly for literal named DOM events, ids, or retry metadata. Likewise,EventStream(generator, event_type="notice")names plain string and dict frames; under htmx 4 those are DOM events, not rendered swaps. Yield a Fragmentfor server-rendered HTML delivery.

Reconnect And Replay

Browsers automatically reconnect SSE streams and send the last received event id in theLast-Event-IDrequest header when your stream yields events with id:. Chirp preserves SSEEvent(id=...)on the wire, but it does not store or replay missed events for you — replay is a product concern.

Real-Time HTML with htmx

Htmx 4 preview

The managed preview provisions the version-matchedhx-sseextension. Use native connection markup; do not mix the removed htmx 2 attributes into the same connection:

<section hx-sse:connect="/notifications" hx-target="#notification-feed">
  <div id="notification-feed">Server-rendered seed</div>
</section>

An untargeted yielded Fragmentis an unnamed HTML message and follows the connection's normalhx-target / hx-swapcontext. A targeted fragment such asFragment(..., target="notification-feed", swap="beforeend")is rendered once and sent as:

<hx-partial hx-target="#notification-feed" hx-swap="beforeend">
  <!-- rendered named block -->
</hx-partial>

Partial-only and OOB-only messages preserve the main connection target. Empty rendered fragments and selector-unsafe targets fail loudly instead of clearing visible HTML. A namedSSEEvent(event="notification", ...)is a DOM event on the connection source; it is not an HTML swap.

Preview SSE responses remainCache-Control: no-cacheand add Vary: Accept, HX-Request-Typebecause the same route can serve the native fetch dialect or a generic/legacy SSE client.

sse_scope()selects this markup automatically for the exact preview and keeps the htmx 2 shape below for the rollback tier.

Htmx 2 rollback tier

The killer pattern: combine SSE with htmx to push rendered HTML fragments in real-time.

Server:

@app.route("/notifications", referenced=True)
async def notifications():
    async def stream():
        async for event in notification_bus.subscribe():
            yield Fragment("components/notification.html", "alert",
                target="notification",
                message=event.message,
                time=event.timestamp,
            )
    return EventStream(stream())

Client (using htmx SSE extension):

<div hx-ext="sse" sse-connect="/notifications">
  <div sse-swap="notification" hx-swap="beforeend">
    <!-- Fragments are swapped in here -->
  </div>
</div>

The server renders HTML, the browser swaps it in. Zero client-side JavaScript for the rendering logic. See Fragments for how blocks are selected and rendered.

Live Dashboard Example

A more complete example -- a dashboard that streams stats updates:

import asyncio

from chirp import Fragment

@app.route("/dashboard/live", referenced=True)
async def live_stats():
    async def stream():
        while True:
            stats = await get_current_stats()
            # Htmx 2: target is a named event. Htmx 4: target is this DOM id.
            yield Fragment("dashboard.html", "stats_panel",
                           target="stats-update", stats=stats)
            await asyncio.sleep(5)
    return EventStream(stream())
<section hx-ext="sse"
         sse-connect="/dashboard/live"
         hx-disinherit="hx-target hx-swap">
  <div id="stats" sse-swap="stats-update">
    {# Initial stats rendered server-side #}
    {% block stats_panel %}
      ...
    {% endblock %}
  </div>
</section>

Error Boundaries

Chirp isolates rendering failures per-event so one bad block doesn't crash the entire stream.

If aFragmentfails to render:

  • Production (debug=False): a generic named error event is sent and the stream continues.
  • Debug (debug=True): a targeted error update replaces the specific block with actionable inline HTML. Htmx 4 uses an unnamed partial; htmx 2 uses the legacy named channel.
<!-- In debug mode, a failed "presence" block becomes: -->
<div class="chirp-block-error" data-block="presence_list">
  <strong>UndefinedError</strong>: &#x27;users&#x27; is undefined
</div>

All other blocks on the page keep updating normally. The next change event that touches the broken block will attempt to re-render it -- natural recovery without retries.

For reactive streams, if thecontext_builder()function itself raises (e.g., a deleted record), the entire event is skipped and the stream waits for the next change. See Error Reference for the full error hierarchy.

Worker Mode

SSE connections are long-lived: the server holds the HTTP connection open and streams events as they arrive. The one rule to remember — set worker_mode="async"for any app that uses SSE:

config = AppConfig(worker_mode="async")

Connection Lifecycle

Chirp manages the SSE connection lifecycle automatically:

  1. 1

    Event producer

    Consumes the generator, formats events, sends as ASGI body chunks.

  2. 2

    Disconnect monitor

    Watches forhttp.disconnect, cancels the producer when the client disconnects.

  3. 3

    Heartbeat

    Sends: heartbeatcomments on idle to keep the connection alive.

Testing SSE

Use theTestClient.sse()method:

from chirp.testing import TestClient

async def test_notifications():
    async with TestClient(app) as client:
        result = await client.sse("/notifications", max_events=3)
        assert len(result.events) == 3
        assert "notification" in result.events[0].data

See Testing Assertions for SSE testing details.

Next Steps