New in Dash 4.5
A regular callback sends its outputs once, when the function returns. A streaming callback sends them as it produces them: define the callback with async def and yield instead of return, and each yielded value reaches the browser immediately.
Use streaming callbacks when the result arrives in pieces rather than all at once. Typical use cases include large language model (LLM) responses that arrive token by token, progress messages for multi-step work, and live data feeds.
There is no keyword to enable streaming. Dash inspects the decorated function: if it’s an async generator function (an async def that contains yield), the callback streams.
import asyncio
from dash import Dash, Input, LocalSharedStorage, Output, callback, html
app = Dash(shared_storage=LocalSharedStorage())
app.layout = html.Div([
html.Button("Start", id="start-btn"),
html.Div(id="status", children="Ready"),
])
@callback(
Output("status", "children"),
Input("start-btn", "n_clicks"),
prevent_initial_call=True,
)
async def run_steps(n_clicks):
yield "Fetching data..."
await asyncio.sleep(1)
yield "Transforming..."
await asyncio.sleep(1)
yield "Done"
if __name__ == "__main__":
app.run(debug=True)
Each yielded value has the same shape as a regular return value: one value per Output, in the same order. In the example above, the browser shows “Fetching data…”, then “Transforming…”, then “Done”, as each one is yielded.
Streaming callbacks work on all server backends (Flask, FastAPI, and Quart).
Streaming callbacks that run over HTTP need shared storage, which is off by default. Enable it when you create your app:
from dash import Dash, LocalSharedStorage
app = Dash(shared_storage=LocalSharedStorage())
LocalSharedStorage works for apps that run on one machine. For apps that run on more than one host, use RedisSharedStorage. For the other backends and for the DASH_SHARED_STORAGE environment variable, see Shared Storage.
If your app has streaming callbacks that run over HTTP and no shared storage, Dash raises a StreamCallbackError that names those callbacks when it sets up the server. Every request to the app fails with this error until you enable shared storage.
Streaming callbacks that run over WebSocket don’t need shared storage. This applies on FastAPI and Quart when you set websocket_callbacks=True on the app or websocket=True on the callback. On Flask, callbacks stream over HTTP even with these settings, so they still need shared storage.
async def Is RequiredA streaming callback must be an async def generator. A synchronous generator (a plain def with yield) is rejected when the callback is registered:
StreamCallbackError: Streaming callback '...' is a synchronous generator, which is
not supported: a sync generator occupies a server worker for the whole stream.
Define it with 'async def' so it streams on the event loop instead.
A synchronous generator would hold a server worker from its first yield to its last, so a handful of concurrent streams could use up every worker. An async def generator runs on the event loop and gives it up at every await, so one worker can serve many streams at once.
This means any waiting inside the callback needs to be awaited rather than blocking. Use await asyncio.sleep(1) rather than time.sleep(1), and use async clients for I/O.
PatchYielding a full value replaces the output each time. That’s fine for a short status string, but wasteful when each update only adds to what’s already there: the whole value crosses the network, and the renderer rebuilds that part of the page.
Patch describes a change to the existing value instead, so only the difference is sent. It’s the recommended way to update an output from a streaming callback.
This example streams tokens into a dcc.Markdown, appending each one to what’s already displayed:
import asyncio
from dash import Dash, Input, LocalSharedStorage, Output, Patch, State, callback, dcc, html
app = Dash(shared_storage=LocalSharedStorage())
app.layout = html.Div([
dcc.Input(id="prompt", type="text", placeholder="Ask a question"),
html.Button("Send", id="send-btn"),
dcc.Markdown(id="answer", children=""),
])
async def model_tokens(prompt):
"""Stand-in for an LLM client that yields tokens as they arrive."""
for token in f"You asked: {prompt}. Here is the answer.".split(" "):
await asyncio.sleep(0.1)
yield token + " "
@callback(
Output("answer", "children"),
Input("send-btn", "n_clicks"),
State("prompt", "value"),
prevent_initial_call=True,
)
async def answer_prompt(n_clicks, prompt):
# Clear the previous answer before streaming the new one.
yield ""
async for token in model_tokens(prompt):
patch = Patch()
patch += token
yield patch
if __name__ == "__main__":
app.run(debug=True)
A Patch object is a proxy for the output property named in the callback’s Output. Here the output is dcc.Markdown’s children, which is a string, so patch += token appends to it.
For nested structures, index into the patch the way you’d index into the property itself. This example updates a dcc.Graph figure, appending one point per tick rather than resending the figure:
import asyncio
import random
import plotly.graph_objects as go
from dash import Dash, Input, LocalSharedStorage, Output, Patch, callback, dcc, html
app = Dash(shared_storage=LocalSharedStorage())
app.layout = html.Div([
html.Button("Start Feed", id="start-btn"),
dcc.Graph(id="live-chart"),
])
@callback(
Output("live-chart", "figure"),
Input("start-btn", "n_clicks"),
prevent_initial_call=True,
)
async def stream_prices(n_clicks):
# The first yield replaces the whole figure and sets up the trace.
figure = go.Figure(data=[go.Scatter(x=[], y=[], mode="lines")])
figure.update_layout(uirevision="keep")
yield figure
price = 100.0
for tick in range(200):
await asyncio.sleep(0.5)
price += random.uniform(-1.5, 1.5)
# Subsequent yields append one point instead of resending the figure.
patch = Patch()
patch["data"][0]["x"].append(tick)
patch["data"][0]["y"].append(round(price, 2))
yield patch
if __name__ == "__main__":
app.run(debug=True)
The first yield sends a complete figure to set up the trace. Every yield after that appends a single x and y value to figure["data"][0].
Note: Setting
uirevisionon the figure preserves the user’s zoom and pan across updates. Without it, each update resets the view.
no_updateA streaming callback with several outputs yields a tuple, one value per Output. Use no_update for the outputs that aren’t changing in this frame:
import asyncio
from dash import Dash, Input, LocalSharedStorage, Output, callback, html, no_update
app = Dash(shared_storage=LocalSharedStorage())
app.layout = html.Div([
html.Button("Run Report", id="run-btn"),
html.Div(id="progress", children=""),
html.Div(id="result", children=""),
])
@callback(
Output("progress", "children"),
Output("result", "children"),
Input("run-btn", "n_clicks"),
prevent_initial_call=True,
)
async def build_report(n_clicks):
for step in range(1, 4):
yield f"Step {step} of 3...", no_update
await asyncio.sleep(1)
yield "Complete", "Report ready to download"
if __name__ == "__main__":
app.run(debug=True)
If every output in a yielded value is no_update, Dash sends nothing for that iteration.
set_propsset_props works inside a streaming callback, and it applies immediately rather than waiting for the stream to end. Use it to update components that aren’t among the callback’s declared outputs:
from dash import set_props
@callback(
Output("result", "children"),
Input("run-btn", "n_clicks"),
prevent_initial_call=True,
)
async def run(n_clicks):
yield "Working..."
set_props("sidebar-status", {"children": "Job running"})
await asyncio.sleep(2)
yield "Finished"
A streaming callback ends when the generator is exhausted. You can also end it early by raising PreventUpdate, which closes the stream cleanly without sending another update:
from dash.exceptions import PreventUpdate
@callback(
Output("job-status", "children"),
Input("start-btn", "n_clicks"),
prevent_initial_call=True,
)
async def stream_until_ready(n_clicks):
for attempt in range(10):
status = await check_job_status()
if status == "ready":
yield "Job complete"
raise PreventUpdate
yield f"Waiting (attempt {attempt + 1})"
await asyncio.sleep(2)
A streaming callback stays in its loading state for the whole stream, not just until the first yield. Any dcc.Loading component wrapping the output shows its spinner until the stream finishes, and the browser tab title shows “Updating…” for the duration.
To keep the tab title unchanged while a stream runs, add persistent=True to the callback decorator. To turn off the “Updating…” title for every callback in the app, set update_title=None in the Dash constructor. For more information, see Adding CSS & JS and Overriding the Page-Load Template.
Each yielded frame updates its component the same way set_props does, so a callback that takes a streamed output as an Input fires on every frame, not only on the last one. A stream that yields 200 times fires its downstream callbacks 200 times.
Keep this in mind when something expensive depends on a streamed output. Options include streaming into a component nothing depends on and writing the final value to a separate output at the end of the stream:
@callback(
Output("live-text", "children"), # streamed; nothing depends on this
Output("final-text", "data"), # written once; the chain hangs off this
Input("btn", "n_clicks"),
prevent_initial_call=True,
)
async def stream_then_commit(n_clicks):
text = ""
async for chunk in fetch_chunks():
text += chunk
yield text, no_update
yield no_update, text
If a streaming callback raises an error, the stream ends at that point. Frames already sent stay applied, so the page keeps the last value that was successfully streamed, and the error is reported the same way as an error in a regular callback: in the debug overlay when dev tools are on, and in the server logs.
Because updates are already visible when the error happens, a partially updated page is a state your app needs to handle. Consider catching the error inside the callback and yielding a final value that makes the failure visible:
@callback(
Output("output", "children"),
Input("btn", "n_clicks"),
prevent_initial_call=True,
)
async def stream_data(n_clicks):
try:
async for chunk in fetch_chunks():
yield chunk
except ConnectionError:
yield "Connection lost. Try again."
Streaming callbacks use one of two transports, chosen automatically:
You don’t need to choose either one. The same callback code works on both.
If your app already uses WebSocket callbacks, streaming over that connection is the more stable option, so enable them app-wide:
app = Dash(backend="fastapi", websocket_callbacks=True)
Over HTTP, all of a page’s streams share a single connection back to the browser rather than opening one per callback. This matters because browsers limit how many concurrent connections a page may open to one host (typically six). Without multiplexing, a handful of simultaneous streams would use up that budget and block ordinary callbacks and asset requests.
Multiplexing also lets a stream survive a brief network interruption: on reconnect the connection resumes from the last sequence number it received, rather than dropping the frames sent while it was away.
Multiplexing is why streaming callbacks over HTTP need shared storage. The worker process that runs a callback might not be the one that holds the page’s connection, so shared storage carries each stream’s frames between workers. For setup, see Enabling Shared Storage.
Over HTTP, a stream that goes quiet can be closed by a proxy that considers the connection idle. (nginx’s proxy_read_timeout defaults to 60 seconds.)
To prevent this, Dash sends a blank keepalive line when a callback goes 15 seconds without yielding. Change the interval, in milliseconds, with stream_keepalive_interval:
app = Dash(stream_keepalive_interval=30000)
Set it to None or 0 to turn keepalives off.
Each streaming request carries a token that the server signs, and every worker has to verify tokens signed by the others. When your app runs more than one worker process, they all need the same signing key. Otherwise each worker generates its own, and a stream request that lands on a different worker than the one that issued the token is refused. This is the same requirement background callbacks already have. Single-worker apps need no configuration.
Dash takes the key from the server’s secret_key if you set one:
import os
from dash import Dash, LocalSharedStorage
app = Dash(shared_storage=LocalSharedStorage())
app.server.secret_key = os.environ["MY_SECRET_KEY"]
If you don’t, Dash falls back to the DASH_SECRET_KEY environment variable, which it uses for signing only and never assigns to server.secret_key. Setting that variable gives every worker the same key without changing your app code.
On Dash Enterprise, DASH_SECRET_KEY is created for you when you create the app, so multi-worker streaming works with no further setup.
On Plotly Cloud, your app runs one worker per vCPU, so every app size above Standard 1x runs more than one worker. Set a key with either of the methods above.
A streaming response stays open for as long as the callback runs, which is longer than a typical request. Any proxy in front of your app needs to allow that:
proxy_buffering off.Streaming callbacks can’t be combined with:
background=True. Background callbacks return one result from a worker process.mcp_enabled=True and api_endpoint. Both expect a single JSON result.Each raises an error when the callback is registered.
Patch API.set_props-driven streaming and persistent callbacks.