Realtime SSE & WebSocket Streams¶
Configuring EventStreamConfig on QueueEventsConfig.stream mounts ready-to-use Server-Sent Events (SSE) and WebSocket endpoints on your Litestar application, powered by Litestar's ChannelsPlugin.
Runnable Configuration Example¶
"""Configuring SSE and WebSocket event stream endpoints with channel_authorizer and rollover."""
from typing import Any
from litestar import Litestar
from litestar.channels import ChannelsPlugin
from litestar.channels.backends.memory import MemoryChannelsBackend
from litestar_queues import QueueConfig, QueuePlugin, WorkerConfig
from litestar_queues.events import EventDeliveryConfig, EventStreamConfig, QueueEventsConfig, QueueEventScope
async def authorize_tenant_channel(connection: Any, scope: QueueEventScope, key: str | None) -> bool:
"""Authorize stream subscriptions based on request headers and requested scope."""
tenant_header = connection.headers.get("x-tenant-id")
if scope in {"task", "queue"}:
return tenant_header is not None
return scope == "custom" and key == f"tenant:{tenant_header}"
def build_streaming_app() -> Litestar:
"""Construct a Litestar application mounting SSE and WebSocket queue event stream routes."""
channels_plugin = ChannelsPlugin(backend=MemoryChannelsBackend(), arbitrary_channels_allowed=True)
queue_config = QueueConfig(
queue_backend="memory",
execution_backend="local",
worker=WorkerConfig(placement="external"),
events=QueueEventsConfig(
delivery=EventDeliveryConfig(
publish_task_channel=True, publish_queue_channel=True, publish_global_lifecycle=False
),
stream=EventStreamConfig(
path="/queues/events",
transports={"sse", "websocket"},
scopes={"task", "queue", "custom"},
channel_authorizer=authorize_tenant_channel,
heartbeat_interval=25.0,
max_stream_duration=3000.0,
retry_ms=3000,
),
),
)
return Litestar(plugins=[channels_plugin, QueuePlugin(config=queue_config)])
async def main() -> None:
"""Verify SSE and WebSocket stream routes are registered on the Litestar application."""
app = build_streaming_app()
route_paths = {route.path for route in app.routes}
assert any("/queues/events" in path for path in route_paths)
Mounted Stream Endpoints¶
By default (path="/queues/events", or /{namespace}/events when a custom QueueNamespace is active), QueuePlugin registers endpoints for each enabled scope (scopes={"task", "queue", "worker", "global", "custom"}) and transport (transports={"sse", "websocket"}):
| Scope | WebSocket Path | SSE Path | Channel Topic Subscribed |
|---|---|---|---|
"global" |
WS /queues/events |
GET /queues/events/sse |
Global queue event channel |
"task" |
WS /queues/events/tasks/{task_id:str} |
GET /queues/events/sse/tasks/{task_id:str} |
Canonical task channel for task_id |
"queue" |
WS /queues/events/queues/{queue_name:str} |
GET /queues/events/sse/queues/{queue_name:str} |
Canonical logical queue channel |
"worker" |
WS /queues/events/workers/{worker_id:str} |
GET /queues/events/sse/workers/{worker_id:str} |
Canonical worker lifecycle channel |
"custom" |
WS /queues/events/channels/{scope_key:str} |
GET /queues/events/sse/channels/{scope_key:str} |
Adopter-defined custom scope key channel |
Stream Authorization & Guards¶
Protect stream endpoints against unauthorized subscriptions using guards and channel_authorizer on EventStreamConfig:
- Route Guards (
guards=[...]): Standard LitestarGuardcallables evaluated before the SSE or WebSocket handshake proceeds. - Per-Channel Authorizer (
channel_authorizer): Async or sync callable(connection: ASGIConnection, scope: QueueEventScope, key: str | None) -> boolinvoked with the requested scope and path key (task_id,queue_name,worker_id, orscope_key). ReturningFalserejects the subscription with403 Forbidden(or closes the WebSocket). - Unauthenticated Policy (
unauthenticated_access): Defaults to"warn"(emits a startup warning if neitherguardsnorchannel_authorizeris configured). Set to"error"to fail startup if streams are unprotected, or"allow"in local demos.
Planned Stream Rollover, Keepalives & Shielded Cleanup¶
Long-lived SSE and WebSocket connections traverse reverse proxies and serverless platforms (such as Google Cloud Run's 60-minute request ceiling) that forcibly reset idle or expired connections:
- Planned Rollover (
max_stream_duration: float | None = None): When set (for examplemax_stream_duration=3000.0for a 50-minute ceiling under a 60-minute proxy timeout), the stream endpoint cleanly closes the connection aftermax_stream_durationseconds so browserEventSourceor WebSocket clients reconnect smoothly before an abrupt proxy504reset. - SSE Reconnect Hint (
retry_ms: int | None = None): When set (for exampleretry_ms=3000), SSE responses emit an initialretry: 3000frame instructing the browserEventSourceto wait 3 seconds before reconnecting, and echo each event'sevent_idin the SSEid:field so reconnecting clients sendLast-Event-ID. - Keepalive Pings (
heartbeat_interval: float = 25.0): Emits periodic"ping"frames every 25 seconds so intermediate load balancers do not drop quiet subscriptions. - Shielded Disconnect Teardown: On client disconnect or cancellation, stream handlers shield
ChannelsPluginunsubscription (anyio.move_on_after(5.0, shield=True)) so subscriber queues never leak under abrupt client disconnects.
Wire JSON Envelope (camelCase)¶
Browser stream frames serialize QueueEvent fields in camelCase JSON for direct consumption in TypeScript, HTMX, and browser frontends:
{
"id": "8f2a9c4e1b0d4f3a9e8c7b6a5d4f3e21",
"eventKey": "8f2a9c4e1b0d4f3a9e8c7b6a5d4f3e21",
"type": "task.progress",
"scope": "task",
"scopeKey": "tenant:acme",
"taskId": "2c8d8f72-6a4b-4c5c-9c82-5e3f21a09b41",
"taskName": "crawler.index_site",
"queue": "crawler",
"workerId": "worker-1",
"executionBackend": "local",
"executionProfile": null,
"attempt": 1,
"sequence": 2,
"level": null,
"message": "3/6 pages",
"progressCurrent": 3.0,
"progressTotal": 6.0,
"progressPercent": 50.0,
"occurredAt": "2026-11-03T19:30:00+00:00",
"payload": {
"page": 3
}
}
Multi-Process Channels Topology¶
- Single-Process (
placement="asgi"withMemoryChannelsBackend): In single-process development or demos, an in-memoryChannelsPlugin(backend=MemoryChannelsBackend())delivers worker events directly to browser streams in the same process. - Multi-Process / External Workers (
placement="server"or"external"): When workers run in separate OS processes or containers from web workers, configureChannelsPluginwith a shared cross-process backend (such asRedisChannelsStreamBackendorRedisChannelsPubSubBackend) on both the web application and worker configuration so events published by workers reach browser clients connected to any web worker.
Complete Runnable HTMX + Vite Examples¶
The repository includes 10 complete runnable HTMX + litestar-vite realtime streaming applications under examples/:
- In-Memory Single-Process Variants:
- WebSocket app:
examples/htmx_realtime_websocket/app.py(frontend entryexamples/shared/resources/main-ws.tsand partialexamples/shared/templates/partials/stream_mount_ws.html) - SSE app:
examples/htmx_realtime_sse/app.py(frontend entryexamples/shared/resources/main-sse.tsand partialexamples/shared/templates/partials/stream_mount_sse.html) - Shared dashboard template:
examples/shared/templates/index.html - Persistent Backend Variants:
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