SSE and WebSockets#
The plugin can add task, queue, worker, global, and custom stream routes under
/queues/events:
from litestar_queues import QueueConfig
from litestar_queues.events import EventDeliveryConfig, EventStreamConfig, QueueEventsConfig
queue_config = QueueConfig(
events=QueueEventsConfig(
channels=channels_backend,
delivery=EventDeliveryConfig(),
stream=EventStreamConfig(
transports={"sse", "websocket"},
scopes={"task", "queue"},
channel_authorizer=authorize_channel,
),
),
)
Use SSE for server-to-browser updates with automatic browser reconnection. Use
WebSockets when the connection also needs bidirectional application messages.
Both deliver the same JSON QueueEvent envelope.
When QueueEventsConfig.channels is set, generated stream routes use that
same Channels source even if the application registers another
ChannelsPlugin. Without an explicit source, the routes use the registered
plugin. Event publishing and route registration remain separate settings so a
web process can serve events published by another process.
Envelope and delivery semantics#
Top-level JSON fields use camel case, including taskId,
workerId, scopeKey, progressCurrent, occurredAt, and
eventKey. Null-valued keys are preserved; user-owned payload keys are
not renamed. Consumers should deduplicate by eventKey when present and by
id otherwise.
Live streams deliver events; they do not store tasks. Keepalives keep an idle connection open, but they do not prove that a task is healthy. A slow client may miss best-effort events, depending on the Channels backend and its backlog policy. Query Event history when a client must replay events.
Canonical runnable sources#
The memory examples are the complete examples. Each backend copy changes only the queue-storage setup and keeps the same frontend behavior.
WebSocket application:
import asyncio
from pathlib import Path
from litestar import Litestar, get, post
from litestar.channels import ChannelsPlugin
from litestar.channels.backends.memory import MemoryChannelsBackend
from litestar.di import NamedDependency
from litestar.plugins.htmx import HTMXPlugin, HTMXTemplate
from litestar.plugins.jinja import JinjaTemplateEngine
from litestar.response import Template
from litestar.template.config import TemplateConfig
from litestar_vite import PathConfig, ViteConfig, VitePlugin
from litestar_queues import QueueConfig, QueuePlugin, QueueService, WorkerConfig, task
from litestar_queues.events import (
EventBufferConfig,
EventDeliveryConfig,
EventStreamConfig,
QueueEventsConfig,
TaskExecutionContext,
publish_task_log,
)
__all__ = ("index", "restart_demo", "run_crawl", "status_json")
SHARED_ROOT = Path(__file__).parent.parent / "shared"
VITE_ENTRY = "resources/main-ws.ts"
BACKEND_NAME = "memory"
DEMO_STEPS = 6
DEMO_STEP_DELAY = 1
DEMO_KEY = "demo:current"
CRAWL_LINES = (
"The task started.",
"The task is visiting the next page.",
"The task is sleeping before the next page.",
"The task is visiting another page.",
"The task is almost done.",
"The task finished the crawl.",
)
DISCOVERED_PAGE_STEP = DEMO_STEPS // 2
DISCOVERED_PAGE_URL = "https://example.invalid/articles/queues-101"
# -- docs-task-start --
@task("examples.htmx_realtime_websocket.crawl", queue="demo", retries=0, timeout=90)
async def run_crawl(*, _task_context: TaskExecutionContext) -> dict[str, str]:
ctx = _task_context
await publish_task_log("The task started", payload={"task_id": ctx.task_id}, immediate=True)
for step in range(1, DEMO_STEPS + 1):
line = CRAWL_LINES[(step - 1) % len(CRAWL_LINES)]
message = f"{step}/{DEMO_STEPS} - {line}"
await ctx.progress(current=step, total=DEMO_STEPS, message=message, payload={"line": message})
if step == DISCOVERED_PAGE_STEP:
# One meaningful domain event at a real transition, with a payload
# distinct from the generic per-step progress text above.
await ctx.event(
"crawl.page_discovered",
message=f"Discovered {DISCOVERED_PAGE_URL}",
payload={"url": DISCOVERED_PAGE_URL},
)
await asyncio.sleep(DEMO_STEP_DELAY)
return {"task_id": ctx.task_id, "status": "complete"}
# -- docs-task-end --
# -- docs-routes-start --
@get("/")
async def index() -> Template:
return Template("index.html", context={"backend_name": BACKEND_NAME, "vite_entry": VITE_ENTRY})
@get("/demo/status")
async def status_json() -> dict[str, str]:
return {"backend": BACKEND_NAME}
@post("/demo/restart")
async def restart_demo(queue_service: NamedDependency[QueueService]) -> Template:
existing = await queue_service.get_queue_backend().get_task_by_key(DEMO_KEY)
result = await queue_service.enqueue(run_crawl, key=DEMO_KEY, description="Run the HTMX realtime queue event demo")
task_id = str(result.id)
reused = existing is not None and not existing.is_terminal and existing.id == result.id
return HTMXTemplate(
template_name="partials/stream_mount_ws.html",
context={"task_id": task_id, "task_ws_url": f"/queues/events/tasks/{result.id}"},
push_url=False,
re_target="#stream-mount",
trigger_event="queue-demo:started",
params={"taskId": task_id, "reused": reused},
after="swap",
)
# -- docs-routes-end --
# -- docs-app-config-start --
channels = ChannelsPlugin(
backend=MemoryChannelsBackend(history=200),
arbitrary_channels_allowed=True,
subscriber_max_backlog=1000,
subscriber_backlog_strategy="dropleft",
)
queue_config = QueueConfig(
# This demo runs everything in one process: a process-local queue and a
# process-local Channels backend, so the worker must live here too. The
# Redis and Valkey variants share both across processes instead.
queue_backend="memory",
worker=WorkerConfig(placement="asgi", graceful_shutdown_timeout=5),
events=QueueEventsConfig(
channels=channels,
delivery=EventDeliveryConfig(buffer=EventBufferConfig(batch_size=8, flush_interval=0.2)),
stream=EventStreamConfig(
scopes={"task"},
replay_limit=25,
heartbeat_interval=15,
unauthenticated_access="allow",
transports={"websocket"},
),
),
)
vite_config = ViteConfig(enabled=True, mode="htmx", paths=PathConfig(root=SHARED_ROOT, resource_dir="resources"))
app = Litestar(
route_handlers=[index, status_json, restart_demo],
template_config=TemplateConfig(directory=SHARED_ROOT / "templates", engine=JinjaTemplateEngine),
signature_namespace={"NamedDependency": NamedDependency},
plugins=[HTMXPlugin(), channels, QueuePlugin(queue_config), VitePlugin(config=vite_config)],
)
# -- docs-app-config-end --
WebSocket frontend. All ten examples share one Vite project under
examples/shared, with an entry point per transport:
// docs: htmx-extension-start
import htmx from "htmx.org"
import { registerHtmxExtension } from "litestar-vite-plugin/helpers"
import "./styles.css"
// htmx 2's ESM build never attaches itself to window, and the extensions read
// the global. Publish it first, register the litestar JSON extension, then load
// the WebSocket extension AFTER the global exists (a dynamic import runs in
// order, unlike a hoisted static import). Vite loads this module async, so it
// can run after DOMContentLoaded has already fired while readyState is
// "interactive" — htmx's own boot listener misses that window, so process the
// DOM explicitly.
;(window as unknown as { htmx: typeof htmx }).htmx = htmx
registerHtmxExtension()
void import("htmx-ext-ws").then(() => htmx.process(document.body))
// docs: htmx-extension-end
const MAX_LINES = 40
// After the final event, wait for the last lines to drift out of view, then
// return to the idle hint unless another task starts first.
const IDLE_DELAY_MS = 45_000
const IDLE_TEXT = document.querySelector<HTMLElement>("#crawl-lines p")?.textContent ?? "Press restart to send a background task."
let idleTimer: ReturnType<typeof setTimeout> | null = null
type QueueEvent = {
type: string
message?: string | null
payload?: Record<string, unknown>
}
function parseQueueEvent(raw: string): QueueEvent | null {
try {
return JSON.parse(raw) as QueueEvent
} catch {
return null
}
}
function eventLine(event: QueueEvent): string | null {
if (event.message) return event.message
if (typeof event.payload?.line === "string") return event.payload.line
return null
}
function setReadout(text: string, completed = false): void {
const readout = document.querySelector<HTMLElement>("#job-readout")
if (!readout) return
readout.textContent = text
readout.classList.toggle("completed", completed)
}
function appendCrawlLine(event: QueueEvent): void {
const target = document.querySelector<HTMLElement>("#crawl-lines")
if (!target) return
const text = eventLine(event)
if (!text || target.lastElementChild?.textContent === text) return
const line = document.createElement("p")
line.textContent = text
line.dataset.eventType = event.type
target.append(line)
while (target.children.length > MAX_LINES) target.firstElementChild?.remove()
}
function markBackendMessage(event: QueueEvent): void {
const status = document.querySelector<HTMLElement>("#delivery-status")
const label = document.querySelector<HTMLElement>("#delivery-label")
if (!status || !label) return
label.textContent = `Backend message received · ${event.type}`
status.classList.remove("received")
void status.offsetHeight
status.classList.add("received")
}
// The one adapter the extensions cannot replace: queue frames are JSON, so they
// cannot be swapped as HTML. Parse each frame, ignore ping heartbeats, and
// append the event line. The terminal event only flips the readout — the last
// progress line is already the on-screen finale.
function handleQueueEvent(raw: string): void {
const event = parseQueueEvent(raw)
if (!event || event.type === "ping") return
markBackendMessage(event)
if (event.type === "task.completed") {
setReadout("Task complete", true)
if (idleTimer) clearTimeout(idleTimer)
idleTimer = setTimeout(goIdle, IDLE_DELAY_MS)
return
}
appendCrawlLine(event)
}
function goIdle(): void {
const plane = document.querySelector<HTMLElement>("#crawl-lines")
if (!plane) return
const hint = document.createElement("p")
hint.textContent = IDLE_TEXT
plane.replaceChildren(hint)
plane.classList.add("idle")
setReadout("Awaiting launch")
}
function resetCrawl(taskId?: string, reused = false): void {
if (idleTimer) clearTimeout(idleTimer)
idleTimer = null
const plane = document.querySelector<HTMLElement>("#crawl-lines")
if (plane) {
plane.classList.remove("idle")
plane.replaceChildren()
// The crawl keyframe is one-shot and time-based; restart it so every
// task's lines enter from the bottom instead of mid-flight.
plane.style.animation = "none"
void plane.offsetHeight
plane.style.animation = ""
}
setReadout(
taskId
? reused
? `Task ${taskId} is already running`
: `Task ${taskId} queued`
: "Task queued",
)
}
// docs: websocket-client-start
// htmx-ext-ws fires htmx:wsBeforeMessage (cancelable) for every socket message
// before it would swap. detail.message is the raw JSON string; preventDefault
// keeps htmx from injecting JSON into the DOM.
document.body.addEventListener("htmx:wsBeforeMessage", (event) => {
event.preventDefault()
const detail = (event as CustomEvent<{ message: string }>).detail
handleQueueEvent(detail.message)
})
// docs: websocket-client-end
// docs: stream-adapter-start
// HTMXTemplate(trigger_event="queue-demo:started") fires after the restart swap
// replaces #stream-mount. Swapping the element reconnects the stream (the old
// WebSocket closes with the removed element); reset the crawl to match.
document.body.addEventListener("queue-demo:started", (event) => {
const detail = (event as CustomEvent<{ taskId?: string; reused?: boolean }>).detail
resetCrawl(detail?.taskId, detail?.reused === true)
})
// docs: stream-adapter-end
<!--
Returned by POST /demo/restart. This element IS the WebSocket connection: the
htmx WS extension opens a socket for ws-connect when the element is swapped in,
and closes it automatically when the next restart replaces the element.
Connection lifecycle == swap lifecycle. Every queue message fires
htmx:wsBeforeMessage; the JS adapter renders it as a crawl line and cancels the
default HTML swap so raw JSON is never injected into the DOM.
-->
<div
id="stream-mount"
class="stream-mount"
aria-hidden="true"
hx-ext="ws"
ws-connect="{{ task_ws_url }}"
data-task-id="{{ task_id }}"
></div>
SSE application:
import asyncio
from pathlib import Path
from litestar import Litestar, get, post
from litestar.channels import ChannelsPlugin
from litestar.channels.backends.memory import MemoryChannelsBackend
from litestar.di import NamedDependency
from litestar.plugins.htmx import HTMXPlugin, HTMXTemplate
from litestar.plugins.jinja import JinjaTemplateEngine
from litestar.response import Template
from litestar.template.config import TemplateConfig
from litestar_vite import PathConfig, ViteConfig, VitePlugin
from litestar_queues import QueueConfig, QueuePlugin, QueueService, WorkerConfig, task
from litestar_queues.events import (
EventBufferConfig,
EventDeliveryConfig,
EventStreamConfig,
QueueEventsConfig,
TaskExecutionContext,
publish_task_log,
)
__all__ = ("index", "restart_demo", "run_crawl", "status_json")
SHARED_ROOT = Path(__file__).parent.parent / "shared"
VITE_ENTRY = "resources/main-sse.ts"
BACKEND_NAME = "memory"
DEMO_STEPS = 6
DEMO_STEP_DELAY = 1
DEMO_KEY = "demo:current"
CRAWL_LINES = (
"The task started.",
"The task is visiting the next page.",
"The task is sleeping before the next page.",
"The task is visiting another page.",
"The task is almost done.",
"The task finished the crawl.",
)
DISCOVERED_PAGE_STEP = DEMO_STEPS // 2
DISCOVERED_PAGE_URL = "https://example.invalid/articles/queues-101"
# -- docs-task-start --
@task("examples.htmx_realtime_sse.crawl", queue="demo", retries=0, timeout=90)
async def run_crawl(*, _task_context: TaskExecutionContext) -> dict[str, str]:
ctx = _task_context
await publish_task_log("The task started", payload={"task_id": ctx.task_id}, immediate=True)
for step in range(1, DEMO_STEPS + 1):
line = CRAWL_LINES[(step - 1) % len(CRAWL_LINES)]
message = f"{step}/{DEMO_STEPS} - {line}"
await ctx.progress(current=step, total=DEMO_STEPS, message=message, payload={"line": message})
if step == DISCOVERED_PAGE_STEP:
# One meaningful domain event at a real transition, with a payload
# distinct from the generic per-step progress text above.
await ctx.event(
"crawl.page_discovered",
message=f"Discovered {DISCOVERED_PAGE_URL}",
payload={"url": DISCOVERED_PAGE_URL},
)
await asyncio.sleep(DEMO_STEP_DELAY)
return {"task_id": ctx.task_id, "status": "complete"}
# -- docs-task-end --
# -- docs-routes-start --
@get("/")
async def index() -> Template:
return Template("index.html", context={"backend_name": BACKEND_NAME, "vite_entry": VITE_ENTRY})
@get("/demo/status")
async def status_json() -> dict[str, str]:
return {"backend": BACKEND_NAME}
@post("/demo/restart")
async def restart_demo(queue_service: NamedDependency[QueueService]) -> Template:
existing = await queue_service.get_queue_backend().get_task_by_key(DEMO_KEY)
result = await queue_service.enqueue(run_crawl, key=DEMO_KEY, description="Run the HTMX realtime queue event demo")
task_id = str(result.id)
reused = existing is not None and not existing.is_terminal and existing.id == result.id
return HTMXTemplate(
template_name="partials/stream_mount_sse.html",
context={"task_id": task_id, "task_sse_url": f"/queues/events/sse/tasks/{result.id}"},
push_url=False,
re_target="#stream-mount",
trigger_event="queue-demo:started",
params={"taskId": task_id, "reused": reused},
after="swap",
)
# -- docs-routes-end --
# -- docs-app-config-start --
channels = ChannelsPlugin(
backend=MemoryChannelsBackend(history=200),
arbitrary_channels_allowed=True,
subscriber_max_backlog=1000,
subscriber_backlog_strategy="dropleft",
)
queue_config = QueueConfig(
# This demo runs everything in one process: a process-local queue and a
# process-local Channels backend, so the worker must live here too. The
# Redis and Valkey variants share both across processes instead.
queue_backend="memory",
worker=WorkerConfig(placement="asgi", graceful_shutdown_timeout=5),
events=QueueEventsConfig(
channels=channels,
delivery=EventDeliveryConfig(buffer=EventBufferConfig(batch_size=8, flush_interval=0.2)),
stream=EventStreamConfig(
scopes={"task"}, replay_limit=25, heartbeat_interval=15, unauthenticated_access="allow", transports={"sse"}
),
),
)
vite_config = ViteConfig(enabled=True, mode="htmx", paths=PathConfig(root=SHARED_ROOT, resource_dir="resources"))
app = Litestar(
route_handlers=[index, status_json, restart_demo],
template_config=TemplateConfig(directory=SHARED_ROOT / "templates", engine=JinjaTemplateEngine),
signature_namespace={"NamedDependency": NamedDependency},
plugins=[HTMXPlugin(), channels, QueuePlugin(queue_config), VitePlugin(config=vite_config)],
)
# -- docs-app-config-end --
SSE frontend:
// docs: htmx-extension-start
import htmx from "htmx.org"
import { registerHtmxExtension } from "litestar-vite-plugin/helpers"
import "./styles.css"
// htmx 2's ESM build never attaches itself to window, and the extensions read
// the global. Publish it first, register the litestar JSON extension, then load
// the SSE extension AFTER the global exists (a dynamic import runs in order,
// unlike a hoisted static import). Vite loads this module async, so it can run
// after DOMContentLoaded has already fired while readyState is "interactive" —
// htmx's own boot listener misses that window, so process the DOM explicitly.
;(window as unknown as { htmx: typeof htmx }).htmx = htmx
registerHtmxExtension()
void import("htmx-ext-sse").then(() => htmx.process(document.body))
// docs: htmx-extension-end
const MAX_LINES = 40
// After the final event, wait for the last lines to drift out of view, then
// return to the idle hint unless another task starts first.
const IDLE_DELAY_MS = 45_000
const IDLE_TEXT = document.querySelector<HTMLElement>("#crawl-lines p")?.textContent ?? "Press restart to send a background task."
let idleTimer: ReturnType<typeof setTimeout> | null = null
type QueueEvent = {
type: string
message?: string | null
payload?: Record<string, unknown>
}
function parseQueueEvent(raw: string): QueueEvent | null {
try {
return JSON.parse(raw) as QueueEvent
} catch {
return null
}
}
function eventLine(event: QueueEvent): string | null {
if (event.message) return event.message
if (typeof event.payload?.line === "string") return event.payload.line
return null
}
function setReadout(text: string, completed = false): void {
const readout = document.querySelector<HTMLElement>("#job-readout")
if (!readout) return
readout.textContent = text
readout.classList.toggle("completed", completed)
}
function appendCrawlLine(event: QueueEvent): void {
const target = document.querySelector<HTMLElement>("#crawl-lines")
if (!target) return
const text = eventLine(event)
if (!text || target.lastElementChild?.textContent === text) return
const line = document.createElement("p")
line.textContent = text
line.dataset.eventType = event.type
target.append(line)
while (target.children.length > MAX_LINES) target.firstElementChild?.remove()
}
function markBackendMessage(event: QueueEvent): void {
const status = document.querySelector<HTMLElement>("#delivery-status")
const label = document.querySelector<HTMLElement>("#delivery-label")
if (!status || !label) return
label.textContent = `Backend message received · ${event.type}`
status.classList.remove("received")
void status.offsetHeight
status.classList.add("received")
}
// The one adapter the extensions cannot replace: queue frames are JSON, so they
// cannot be swapped as HTML. Parse each frame, ignore ping heartbeats, and
// append the event line. The terminal event only flips the readout — the last
// progress line is already the on-screen finale.
function handleQueueEvent(raw: string): void {
const event = parseQueueEvent(raw)
if (!event || event.type === "ping") return
markBackendMessage(event)
if (event.type === "task.completed") {
setReadout("Task complete", true)
if (idleTimer) clearTimeout(idleTimer)
idleTimer = setTimeout(goIdle, IDLE_DELAY_MS)
return
}
appendCrawlLine(event)
}
function goIdle(): void {
const plane = document.querySelector<HTMLElement>("#crawl-lines")
if (!plane) return
const hint = document.createElement("p")
hint.textContent = IDLE_TEXT
plane.replaceChildren(hint)
plane.classList.add("idle")
setReadout("Awaiting launch")
}
function resetCrawl(taskId?: string, reused = false): void {
if (idleTimer) clearTimeout(idleTimer)
idleTimer = null
const plane = document.querySelector<HTMLElement>("#crawl-lines")
if (plane) {
plane.classList.remove("idle")
plane.replaceChildren()
// The crawl keyframe is one-shot and time-based; restart it so every
// task's lines enter from the bottom instead of mid-flight.
plane.style.animation = "none"
void plane.offsetHeight
plane.style.animation = ""
}
setReadout(
taskId
? reused
? `Task ${taskId} is already running`
: `Task ${taskId} queued`
: "Task queued",
)
}
// docs: sse-client-start
// htmx-ext-sse fires htmx:sseBeforeMessage (cancelable) for each named frame in
// sse-swap before it would swap. detail is the raw MessageEvent; preventDefault
// keeps htmx from injecting JSON into the DOM.
document.body.addEventListener("htmx:sseBeforeMessage", (event) => {
event.preventDefault()
const message = (event as CustomEvent<MessageEvent<string>>).detail
handleQueueEvent(message.data)
})
// docs: sse-client-end
// docs: stream-adapter-start
// HTMXTemplate(trigger_event="queue-demo:started") fires after the restart swap
// replaces #stream-mount. Swapping the element reconnects the stream (the old
// EventSource closes with the removed element); reset the crawl to match.
document.body.addEventListener("queue-demo:started", (event) => {
const detail = (event as CustomEvent<{ taskId?: string; reused?: boolean }>).detail
resetCrawl(detail?.taskId, detail?.reused === true)
})
// docs: stream-adapter-end
<!--
Returned by POST /demo/restart. This element IS the SSE connection: the htmx
SSE extension opens an EventSource for sse-connect when the element is swapped
in, and closes it automatically when the next restart replaces the element.
Connection lifecycle == swap lifecycle. sse-swap lists every queue event type
so htmx:sseBeforeMessage fires for each; the JS adapter renders them as crawl
lines and cancels the default HTML swap (hx-swap="none" belt-and-suspenders).
-->
<div
id="stream-mount"
class="stream-mount"
aria-hidden="true"
hx-ext="sse"
sse-connect="{{ task_sse_url }}"
sse-swap="task.started,task.progress,task.log,crawl.page_discovered,task.completed,task.failed"
hx-swap="none"
data-task-id="{{ task_id }}"
></div>
The page markup both transports render is shared too:
{% extends "base.html" %}
{% block content %}
<!-- docs: template-start -->
<main class="stage" data-demo-root>
<div class="starfield" aria-hidden="true">
<div class="stars stars-far"></div>
<div class="stars stars-mid"></div>
<div class="stars stars-near"></div>
</div>
<div class="nebula" aria-hidden="true"></div>
<a class="brand" href="https://litestar.dev" aria-label="Litestar">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" role="img" aria-hidden="true">
<g stroke="#edb641" stroke-width="3.4" stroke-linecap="round">
<line x1="24.4" y1="16.2" x2="36.6" y2="17.8"/>
<line x1="39.5" y1="25.4" x2="25.6" y2="41.6"/>
<line x1="46.3" y1="27.4" x2="47.8" y2="37.6"/>
<line x1="28.5" y1="47.4" x2="40.5" y2="46.6"/>
</g>
<g stroke="#edb641" stroke-width="3.4" fill="none">
<circle cx="16" cy="15" r="6"/>
<circle cx="45" cy="19" r="6"/>
<circle cx="20" cy="48" r="6"/>
<circle cx="49" cy="46" r="6"/>
</g>
</svg>
</a>
<!-- litestar-vite JSON-template extension: a whisper-quiet backend-name caption. -->
<div class="backend-caption" hx-ext="litestar" hx-get="/demo/status" hx-trigger="load" hx-swap="json">
<template ls-if="backend"><span>${backend}</span></template>
<template ls-else><span>{{ backend_name }}</span></template>
</div>
<div class="crawl-perspective" aria-live="polite" aria-label="Task events">
<div id="crawl-lines" class="crawl-plane idle">
<p>Press restart to send a background task and watch its messages arrive.</p>
</div>
</div>
<p class="telemetry-caption"><span class="telemetry-dot" aria-hidden="true"></span>Live messages · each update is pushed from the backend job</p>
<div id="delivery-status" class="delivery-status" role="status" aria-live="polite">
<span class="delivery-pulse" aria-hidden="true"></span>
<span id="delivery-label">Waiting for a backend message</span>
</div>
<p id="job-readout" class="readout">Awaiting launch</p>
<button
class="planet"
type="button"
hx-post="/demo/restart"
hx-target="#stream-mount"
hx-swap="outerHTML"
hx-indicator="this"
hx-disabled-elt="this"
hx-sync="this:replace"
aria-label="Restart the demo queue task"
>
<span class="planet-orbit" aria-hidden="true"><span class="planet-satellite"></span></span>
<span class="planet-label">Restart</span>
<span class="planet-launching" aria-hidden="true">Launching</span>
</button>
<!-- The htmx SSE extension owns this element after each restart swaps it. -->
<div id="stream-mount" class="stream-mount" aria-hidden="true"></div>
</main>
<!-- docs: template-end -->
{% endblock %}
Backend variants#
The gallery includes these runnable source families:
examples/htmx_realtime_websocket_sqlspecandexamples/htmx_realtime_sse_sqlspecexamples/htmx_realtime_websocket_advanced_alchemyandexamples/htmx_realtime_sse_advanced_alchemyexamples/htmx_realtime_websocket_redisandexamples/htmx_realtime_sse_redisexamples/htmx_realtime_websocket_valkeyandexamples/htmx_realtime_sse_valkey
The browser test clicks Restart, observes the enqueue request and page updates, then waits for the final event. A curl stream cannot prove that the HTMX extension starts or that the browser opens and closes the connection correctly.