Add streaming callbacks: @callback(..., stream=True)#3888
Conversation
A callback registered with stream=True is a generator (or async
generator) whose yields are pushed to the browser as they are produced.
Each yielded value has the same shape as a regular return value and
replaces the outputs; yielding dash.Patch gives incremental updates
(e.g. LLM token streaming). The last yield is the final value.
Transport follows the callback's normal selection: over the WebSocket
callback transport, frames ride the open connection as callback_response
messages with stream: true; otherwise the HTTP response streams NDJSON
(application/x-ndjson), one frame per line with a terminal {"done": true}
frame. Works on Flask, Quart, and FastAPI; sync generators warn at
registration since they occupy a server worker for the whole stream.
- dash/_streaming.py: StreamedCallbackResponse marker, context-safe
iteration helpers (callback context travels in a contextvars snapshot
so set_props/ctx work after dispatch returns), NDJSON serialization,
and a thread bridge for async generators on Flask.
- dash/_callback.py: stream wrappers building one frame per yield via
_prepare_response; per-yield no_update/PreventUpdate handling,
on_error support, error frames; registration validation (mutually
exclusive with background/clientside/mcp_enabled/api_endpoint).
- backends: streaming response branches in each serve_callback;
ws.py frame emitter and stream consumers for both the event-loop and
threadpool dispatch paths.
- renderer: applyStreamFrame applies frames on arrival through the
sideUpdate path (Patch applies exactly once); NDJSON reader in
handleServerside; stream-aware callback_response handling keeps the
request pending until the terminal frame; loading states span the
whole stream.
fastapi.testclient imports starlette.testclient, which requires httpx. Skip the protocol-level stream tests when httpx is not installed and add httpx to the CI requirements so the websocket job actually runs them.
|
KoolADE85
left a comment
There was a problem hiding this comment.
The implementation here will easily starve the server of resources.
Run the sample app with gunicorn -w 1 and observe:
- It will terminate streams that take longer than 30s (by default)
- The number of simultaneous requests is limited to the number of workers you spawn. After that limit, the server becomes unresponsive and/or terminates worker threads.
| stream = _kwargs.get("stream", False) | ||
| is_gen_func = inspect.isgeneratorfunction(func) | ||
| is_async_gen_func = inspect.isasyncgenfunction(func) | ||
| if stream: | ||
| if not (is_gen_func or is_async_gen_func): | ||
| raise StreamCallbackError( | ||
| f"stream=True callback '{callback_id}' must be a generator " | ||
| "function (or async generator function) that yields output " | ||
| "updates." | ||
| ) |
There was a problem hiding this comment.
This looks like two "sources of truth" that must agree with each other (stream=True and async/generator).
What do you think about removing the stream flag and just detect streaming automatically based on isasyncgenfunction/ isgeneratorfunction? That would allow devs to just start yielding values without needing to manage flags or understand our API (because there would be no API at all).
There was a problem hiding this comment.
Good catch, only need a yield, stream keyword is redundant.



A callback registered with
stream=Trueis a generator (or async generator) whose yields are pushed to the browser as they are produced. Each yielded value has the same shape as a regular return value and replaces the outputs; yieldingdash.Patchgives incremental updates (e.g. LLM token streaming). The last yield is the final value.Transport follows the callback's normal selection: over the WebSocket callback transport, frames ride the open connection as callback_response messages with stream: true; otherwise the HTTP response streams NDJSON (application/x-ndjson), one frame per line with a terminal
{"done": true}frame. Works on Flask, Quart, and FastAPI; sync generators warn at registration since they occupy a server worker for the whole stream.dash/_streaming.py: StreamedCallbackResponse marker, context-safe iteration helpers (callback context travels in a contextvars snapshot soset_props/ctxwork after dispatch returns), NDJSON serialization, and a thread bridge for async generators on Flask.dash/_callback.py: stream wrappers building one frame per yield via _prepare_response; per-yieldno_update/PreventUpdatehandling, on_error support, error frames; registration validation (mutually exclusive with background/clientside/mcp_enabled/api_endpoint).backends: streaming response branches in each serve_callback;ws.pyframe emitter and stream consumers for both the event-loop and threadpool dispatch paths.renderer: applyStreamFrame applies frames on arrival through the sideUpdate path (Patch applies exactly once); NDJSON reader in handleServerside; stream-aware callback_response handling keeps the request pending until the terminal frame; loading states span the whole stream.Demo
Two examples:
appends to the output while the callback keeps running.
bar width/label, and set_props pushes a side update along the way.