Skip to content

Add streaming callbacks: @callback(..., stream=True) - #3888

Open
T4rk1n wants to merge 5 commits into
devfrom
feat/stream-callbacks
Open

Add streaming callbacks: @callback(..., stream=True)#3888
T4rk1n wants to merge 5 commits into
devfrom
feat/stream-callbacks

Conversation

@T4rk1n

@T4rk1n T4rk1n commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

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.

Demo

Two examples:

  1. Fake LLM token streaming — each token is yielded as a dash.Patch that
    appends to the output while the callback keeps running.
  2. A long computation with a live progress bar — each yield replaces the
    bar width/label, and set_props pushes a side update along the way.
import asyncio
import random

from dash import Dash, Input, Output, Patch, callback, html, set_props

LOREM = (
    "Streaming callbacks let a generator push every yield straight to the "
    "browser while the callback is still running which is exactly what you "
    "want for token streaming progress feeds and long computations"
).split()

app = Dash(__name__)

app.layout = html.Div(
    style={"maxWidth": "640px", "margin": "40px auto", "fontFamily": "sans-serif"},
    children=[
        html.H2("stream=True demo"),
        html.H4("1. Token streaming (Patch appends)"),
        html.Button("Generate", id="generate", n_clicks=0),
        html.Div(
            id="answer",
            children="Click Generate…",
            style={
                "whiteSpace": "pre-wrap",
                "border": "1px solid #ccc",
                "borderRadius": "6px",
                "padding": "12px",
                "margin": "12px 0 32px",
                "minHeight": "60px",
            },
        ),
        html.H4("2. Long computation with progress"),
        html.Button("Run job", id="run-job", n_clicks=0),
        html.Div(
            style={
                "background": "#eee",
                "borderRadius": "6px",
                "margin": "12px 0",
                "height": "22px",
                "overflow": "hidden",
            },
            children=html.Div(
                id="bar",
                style={
                    "background": "#119dff",
                    "height": "100%",
                    "width": "0%",
                    "transition": "width 0.2s",
                },
            ),
        ),
        html.Div(id="status", children=""),
    ],
)


@callback(
    Output("answer", "children"),
    Input("generate", "n_clicks"),
    stream=True,
    prevent_initial_call=True,
)
async def generate(n):
    yield ""  # clear the output immediately
    for word in LOREM:
        await asyncio.sleep(random.uniform(0.03, 0.15))  # pretend to think
        patch = Patch()
        patch += word + " "
        yield patch  # append one token


@callback(
    Output("bar", "style"),
    Output("status", "children"),
    Input("run-job", "n_clicks"),
    stream=True,
    prevent_initial_call=True,
)
async def run_job(n):
    style = Patch()
    for i in range(1, 101):
        await asyncio.sleep(0.04)
        style["width"] = f"{i}%"
        yield style, f"Processing step {i}/100…"
        if i == 50:
            # set_props between yields rides along with the next frame
            set_props("answer", {"children": "Halfway there — set_props says hi."})
    yield style, "Done ✅"


if __name__ == "__main__":
    app.run(debug=True)

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 KoolADE85 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation here will easily starve the server of resources.
Run the sample app with gunicorn -w 1 and observe:

  1. It will terminate streams that take longer than 30s (by default)
  2. 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.

Comment thread dash/_callback.py Outdated
Comment on lines +1098 to +1107
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."
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, only need a yield, stream keyword is redundant.

T4rk1n added 2 commits July 27, 2026 13:18
A stream=True callback that goes quiet between yields sends no bytes, so
every proxy in a typical deployment eventually closes the connection on
its own idle timeout (nginx's proxy_read_timeout defaults to 60s). The
renderer treats a stream that ends without a terminal frame as a dropped
connection, so this looked like silent truncation.

The NDJSON transports now emit a blank line every
stream_keepalive_interval milliseconds (new Dash argument, default 15000,
None or 0 disables) that the callback spends between yields. The renderer
already skips blank lines, so no client change is needed. The WebSocket
transport is unaffected -- it has websocket_heartbeat_interval.

The two paths need different mechanics. The async path holds the pending
__anext__ across timeouts; asyncio.wait_for would cancel the user
generator mid-step every time a keepalive came due. The sync path drives
the frame generator on a pump thread, since a blocking next() cannot be
interrupted on a timer -- one consequence is that a client disconnect no
longer raises GeneratorExit into the user generator at its current yield,
which is one more reason sync generators warn at registration.

Note this does not address gunicorn's worker timeout, which kills the
worker rather than the connection and is only fixable via --worker-class.
@T4rk1n

T4rk1n commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

The implementation here will easily starve the server of resources. Run the sample app with gunicorn -w 1 and observe:

1. It will terminate streams that take longer than 30s (by default)

2. 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.

With a sync flask backend, it's not really possible to fix at the app level. Need to run with --worker-class gthread --threads 8 --timeout 0 for a true fix. Will add support in cloud and DE.

There is also a warning recommending a different backend when streaming, but maybe we should not allow it?

@chgiesse

Copy link
Copy Markdown
Contributor

@T4rk1n I solved that issue with my event streaming package by limiting the event stream to the normal 30 sec server timeout. With that, the stream stays in the scope of a normal http request while still providing its advantages.

@T4rk1n

T4rk1n commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

@T4rk1n I solved that issue with my event streaming package by limiting the event stream to the normal 30 sec server timeout. With that, the stream stays in the scope of a normal http request while still providing its advantages.

This is too limiting for LLM purposes, conversations can last a long time.

It's entirely out of the application scope since we now support multiple backends and there is a big difference in how uvicorn and gunicorn handle this by default:

  • gunicorn: default is a flat 30s timeout, no reset on messages.
  • uvicorn: 60s seconds resets on every message, a keep alive pattern is all you need.

Then there is also reverse proxy (nginx & such) settings that might get in the way.

We'll configure plotly-cloud/enterprise to accept the streaming properly without user configuration and have some documentation for flask apps and gunicorn.

A generator callback streams by definition, so the opt-in keyword was
redundant: the only two states it could express beyond the function's own
shape were both already errors (stream=True on a non-generator, and a
generator function registered without stream=True). Decorating a generator
or async generator function is now enough.

register_callback detects the generator and flips callback_map[...]["stream"],
and the combination checks move with it: _validate_stream_callback rejects
background=True, mcp_enabled=True and api_endpoint at registration, keyed off
the detected function shape rather than the keyword. The clientside guard goes
away with the keyword it rejected.

Also drops `stream` from the renderer's ICallbackDefinition, which was never
read -- the client keys off the NDJSON content type on HTTP and the stream /
done markers on WebSocket responses, both of which live on the response types.
@sonarqubecloud

Copy link
Copy Markdown

Comment thread dash/_callback.py
Comment on lines +1169 to +1173
warnings.warn(
f"Streaming callback '{callback_id}' is a synchronous "
"generator; it will occupy a server worker for the whole "
"stream. Define it with 'async def' so it runs on the "
"event loop instead.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this warning understates the potential problem.

Suggested change
warnings.warn(
f"Streaming callback '{callback_id}' is a synchronous "
"generator; it will occupy a server worker for the whole "
"stream. Define it with 'async def' so it runs on the "
"event loop instead.",
warnings.warn(
f"Callback '{callback_id}' is a synchronous generator; "
"it can slow down or stall the entire app under heavy load. "
"Consider 'async def' instead for better performance.",

Comment thread dash/backends/ws.py
Comment on lines +582 to +583
if ws_callback.is_shutdown:
return {"status": "prevent_update"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 bugs around a similar root cause here (also applies to the async version below):

  1. hot-reloading is broken while any stream is alive
  2. streams are left running orphaned after a server shutdown

Here we handle client disconnect - maybe it's also a good place to handle server shutdowns?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants