Dash 4.5: Streaming Callbacks, Shared Storage, and more
The apps people build with Dash have changed. A growing share sit in front of an LLM or a long-running pipeline, and more of them run in production across several worker processes rather than on a single laptop. In the past, these use cases were challenging for the model Dash callbacks were built on: one request in, one response out, with each worker keeping its own memory. Users wait on a spinner for an answer that is being generated word by word, and state that works fine in development quietly fragments once the app scales out.
Dash 4.5 alleviates these issues, while keeping the part of Dash people rely on most: you write plain Python callbacks, and Dash handles the browser.
This release lets a callback send results to the browser while it is still running, and adds an optional server-side store that all of an app's worker processes can read and write. Together they cover two things Dash developers have asked about for years: streaming new results into a data app on the fly, and sharing state across workers without bolting on extra infrastructure.
The release also adds partial pattern matching for callback IDs, social media preview tags, an explicit way to reset a component from a callback, optional request compression, and experimental React 19 support. Pattern-matching callbacks over large layouts are up to 4x faster, and a set of rendering regressions from 4.2 are fixed.
Upgrade today, and read on for details on these exciting new features:
pip install -U dash
Streaming Callbacks
A Dash callback has always worked in one shot: the browser sends inputs, the server computes, and the result arrives when the function returns. That model breaks down when the work takes a while and the user would benefit from seeing it progress. An LLM that takes 20 seconds to answer leaves the user watching a spinner, even though the first words were ready after half a second. A data pipeline with five stages shows nothing until all five finish.
Until now the workarounds involved bolting on additional infrastructure. Background callbacks with progress outputs need a Celery or Diskcache manager and update on a polling interval. A dcc.Interval loop polls the server whether or not anything changed. WebSocket callbacks require an ASGI backend. And holding open one HTTP stream per callback runs into the browser's limit of roughly six connections per host, so a page with several live panels stalls.
In Dash 4.5, a callback written as an async def generator streams automatically. Every yield is sent to the browser as soon as it is produced. There is no new keyword to learn: if the function yields, it streams. Over HTTP, streaming needs shared storage turned on, which takes one constructor argument (covered below).
from anthropic import AsyncAnthropicfrom dash import Dash, html, dcc, callback, Input, Output, State, Patch, LocalSharedStorageapp = Dash(__name__, shared_storage=LocalSharedStorage())client = AsyncAnthropic() # reads ANTHROPIC_API_KEY from the environmentapp.layout = html.Div([dcc.Textarea(id="prompt"),html.Button("Ask", id="ask"),dcc.Markdown(id="answer"),])@callback(Output("answer", "children"),Input("ask", "n_clicks"),State("prompt", "value"),prevent_initial_call=True,)async def answer(n_clicks, prompt):yield "" # clear the previous answerasync with client.messages.stream(model="claude-sonnet-5-5",max_tokens=1024,messages=[{"role": "user", "content": prompt}],) as stream:async for token in stream.text_stream:p = Patch()p += token # append, don't resend the whole stringyield p
This uses the Anthropic SDK (pip install anthropic), but any client that yields text asynchronously works the same way.
Each yield has the same shape as a normal return value and replaces the outputs. Yield a dash.Patch to update incrementally, as above, so a long response only sends the new tokens rather than the full text every time.
The same pattern fits any multi-step job. A report generator can show each stage as it completes:
@callback(Output("status", "children"),Output("report", "figure"),Input("run", "n_clicks"),prevent_initial_call=True,)async def run_report(n):yield "Loading data...", no_updatedf = await load_data()yield "Fitting model...", no_updatemodel = await fit(df)yield "Done", make_figure(df, model)
How it behaves
- no_update works per output within a yield, and raising PreventUpdate mid-stream ends the stream cleanly.
- set_props() calls between yields are delivered with the next update.
- Dependent callbacks fire on each streamed update, and loading states stay on until the stream finishes.
- If the generator raises, frames already shown stay on the page; on_error can supply a final value.
- Closing the browser tab cancels that tab's running streams, so abandoned requests stop using server time.
- Long gaps between yields won't trip proxy idle timeouts: Dash sends a keepalive every 15 seconds by default, configurable with Dash(stream_keepalive_interval=...).
One connection for every stream
Streams don't each hold their own HTTP connection. All of a browser's streams, across every open tab of the app, share a single connection hosted in a SharedWorker. A dashboard with eight live panels open in three tabs uses one connection, not twenty-four, so it never hits the browser's per-host limit and other callbacks on the page keep responding. Long-running streams also no longer count against the renderer's concurrent-request budget.
This works on Flask, Quart, and FastAPI, with no WebSocket required. It does need shared storage (next section): streamed updates travel through it, so the worker running your callback and the worker holding the browser's connection don't have to be the same process. If WebSocket callbacks are enabled on FastAPI or Quart, streams use that connection instead and don't need shared storage.
Requirements
Streaming callbacks must be async def generators on every backend, including Flask; a regular (sync) generator is rejected at registration because it would tie up a server worker for the whole stream. They can't be combined with background=True, mcp_enabled, or api_endpoint, and clientside callbacks can't stream.
Streaming callbacks over HTTP require shared storage. If an app has them and no shared storage, Dash raises a StreamCallbackError naming those callbacks when the server starts, so a misconfigured app fails the same way locally as it would in production.
See the streaming callback docs and #3931 for implementation details.
Shared Storage
One of the first lessons in Dash is not to store state in global variables. In development it seems to work, but in production the app runs as several worker processes (gunicorn -w 4, for example), each with its own memory. A value one worker writes is invisible to the others, and users see inconsistent results depending on which worker answers.
dcc.Store solves this for per-user state by keeping it in the browser. But some state belongs on the server: an expensive result every user should reuse, a job's progress that another callback needs to read, a count shared across sessions, or events one user triggers that others should see. Until now that meant setting up Redis, Flask-Caching, or a database, even for a small app.
Dash 4.5 adds optional shared storage. It's a server-side key/value store plus an ordered publish/subscribe channel, visible to all of the app's worker processes, available as dash.ctx.shared_storage inside a callback or app.shared_storage anywhere else. Turn it on with Dash(shared_storage=LocalSharedStorage()), or set DASH_SHARED_STORAGE=local without changing code. The default backend needs no external service and starts lazily, the first time something uses it.
Key/value with optional expiry
import dashfrom dash import callback, Input, Output@callback(Output("summary", "children"), Input("refresh", "n_clicks"))def summary(n):store = dash.ctx.shared_storageresult = store.get("daily_summary")if result is None:result = compute_expensive_summary()store.set("daily_summary", result, ttl=3600) # reuse for an hourreturn result
The first user to load the page pays for the computation; everyone else, on any worker, gets the cached result until it expires. ttl is in seconds and optional. Values must be JSON-compatible, the same rule as dcc.Store and callback outputs.
Publish/subscribe
Shared storage also carries ordered messages between callbacks. Any callback can publish to a topic, and any other can subscribe and receive messages in order. Paired with a streaming callback, this gives you live updates pushed across users without polling:
@callback(Output("post-status", "children"), Input("send", "n_clicks"),State("message", "value"), prevent_initial_call=True)def post(n, text):dash.ctx.shared_storage.publish("activity", {"text": text})return "Posted"@callback(Output("feed", "children"), Input("feed", "id"))async def live_feed(_):yield []with dash.ctx.shared_storage.subscribe("activity") as sub:async for msg in sub:p = Patch()p.append(html.Div(msg["text"]))yield p
Every open page sees new posts as they arrive, whichever worker handled the post. Subscriptions are replayable: a consumer that reconnects can pass replay_from=<last sequence> to pick up messages it missed, and if it fell too far behind it gets an explicit SharedStorageGap error instead of a silent hole.
Choosing a backend
The right backend depends on how far your state needs to reach. App code is the same for all three; you only need to change the constructor.
Backend
Shares state across
Use when
Install
LocalSharedStorage
All workers in one container
Single server or a single container with multiple workers, including Plotly Cloud
Included (dash[shared_storage] for the persistent modes)
DiskcacheSharedStorage
All processes on one host sharing a directory
You already use Diskcache for background callbacks
dash[diskcache]
RedisSharedStorage
Any process, container, or pod
Multiple containers or pods behind a load balancer
dash[redis]
from dash import Dash, LocalSharedStorage, RedisSharedStorage# In-memory, shared by all workers in one containerapp = Dash(__name__, shared_storage=LocalSharedStorage())# Persisted to disk across restarts (pip install "dash[shared_storage]")app = Dash(__name__, shared_storage=LocalSharedStorage(mode="persist"))# Shared across containers and podsapp = Dash(__name__, shared_storage=RedisSharedStorage(url="redis://host:6379"))# Keep it off, even if DASH_SHARED_STORAGE is setapp = Dash(__name__, shared_storage=None)
One caution for scaled deployments: LocalSharedStorage reaches every worker in a container, but not across containers. If your app runs as multiple pods behind a load balancer, each pod gets its own isolated store, and writes on one won't appear on another. Use RedisSharedStorage there. The default in-memory mode also doesn't survive a restart; use mode="persist" (write-through) or mode="persist-reset" (periodic snapshots) if the key/value data needs to. Both persistent modes need pip install "dash[shared_storage]".
You can also pick a backend without changing code. When an app doesn't pass shared_storage=, Dash reads the DASH_SHARED_STORAGE environment variable: local, diskcache:///abs/path, or a redis:// or rediss:// URL. When the variable is unset, empty, or none, shared storage stays off. On deployments with several workers or pods, also set DASH_SECRET_KEY so they all share one signing secret for stream tokens. Without it, a streaming request handled by a different worker can fail verification, and Dash logs a warning when that happens. An explicit server.secret_key still takes precedence.
DASH_SHARED_STORAGE=redis://host:6379 DASH_SECRET_KEY=change-me gunicorn -w 4 app:server
If none of the three fit, BaseSharedStorage is a public extension point for writing your own backend. See the shared storage docs and #3930 for details.
More New Features
Partial Pattern Matching
Pattern-matching IDs have had to match exactly: an Input({"type": "btn"}, ...) wouldn't fire for a button with ID {"type": "btn", "index": 1, "page": "home"}. That forced every callback to know every key on the components it listens to, which gets awkward once IDs carry extra metadata like a page, section, or owner. Pass partial_pattern=True to Input, Output, or State and the pattern matches any ID that contains its keys, ignoring the rest. It combines with ALL and MATCH.
@callback(Output("total", "children"),Input({"type": "btn", "index": ALL}, "n_clicks", partial_pattern=True),)def total(clicks):return sum(c or 0 for c in clicks)
See pattern matching callbacks and #3765.
Resetting a Component with dash.remount
When a callback returns a component with the same type and id as the one on the page, Dash updates it in place and keeps its internal state, such as a grid's selection or an open dropdown. Usually that's what you want. When it isn't, the only way to get a clean component used to be changing its id. Wrap the return value in dash.remount(...) to force a fresh instance instead.
return remount(dcc.Dropdown(id="region", options=new_options))
Social Media Preview Tags
Links to Dash apps shared on Slack, LinkedIn, or X now get a proper preview card. Set title, description, and image on the app (Dash(__name__, title=..., description=..., image="preview.png")) and Dash emits the matching meta tags. The image can be a file in assets/ or a URL. Previously this only worked with Dash Pages. See #4024.
Smaller Additions
- Callback request compression: set compress_payload=True on a callback to gzip large request payloads, such as a big table passed as State, before they're sent. Payloads over compress_threshold (5,000 bytes by default) are compressed. Works on Flask, FastAPI, and Quart.
- React 19 (experimental): opt in with REACT_VERSION=19.2.4. React 18.3.1 remains the default, and a compatibility shim keeps component libraries built for React 18 and earlier working.
- Partial reads over WebSocket: ws.get_prop(..., path=[...]) reads one nested value from a prop instead of transferring the whole thing, using the same path syntax as Patch.
- dcc.Graph cursor position: hoverData and clickData include xPixel and yPixel when hoveranywhere or clickanywhere is enabled, so you can place overlays or react to clicks on empty plot areas.
- dcc.Link(scrollToTop=False) keeps the scroll position after client-side navigation. The default stays True.
- plotly-cloud is now installed with Dash, so the plotly CLI and Plotly Cloud deployment work without pip install dash[cloud]. The extra still works.
Performance
- Pattern-matching callbacks (MATCH, ALL, ALLSMALLER) no longer slow down quadratically as matching components grow. Updating 400 components with ALL drops from about 580 ms to 140 ms, with no code changes.
- Pages no longer get progressively slower as callbacks append children.
- Patch().append() into large containers is about 16% faster.
- Callback-returned children are reconciled in place again instead of being unmounted and remounted, which made large subtrees 3 to 4x slower since 4.2.
- FastAPI and Quart apps only open a WebSocket connection when the app actually uses WebSocket callbacks.
Fixes
- Patch() updates no longer re-run initial callbacks for components already on the page or wipe their persisted user-edited values.
- Components that set their own initial state on mount, like dash-bootstrap-components Tabs, apply it on first render again.
- Components rendered as props, like dcc.Dropdown option labels and dcc.Tab labels, no longer crash or go stale when their parent is replaced by a callback.
- Fixed intermittent 500 errors ("<lib>" is not a registered library) and DuplicateCallback errors right after a restart under multi-threaded or ASGI workers.
- Fixed callbacks being registered twice under a reloader, with an app factory, or when running python app/app.py from the parent directory.
- dcc.Upload with multiple=True uploads every dragged file again, not just the first.
- dcc.Input(type="number") steppers work when only min is set.
- dcc.Markdown renders <dccLink /> again.
- Date picker inputs no longer show browser autocomplete.
- DASH_PROXY is honored in Jupyter, and apps launched from JupyterLab Desktop on Windows no longer fail with a 500 on load.
- Testing: dash.testing detects wrapped FastAPI and Quart servers correctly, and selenium is unpinned (now >=4.11.0) so tests can drive current Chrome.
Upgrading
Most apps upgrade with no changes. Check these if they apply to you:
- React 16 is no longer supported. REACT_VERSION=16.14.0 will fail; move to 18.3.1 (the default).
- Callback-returned components now keep their internal state when the type and id match, as they did before 4.2. If your app relied on the 4.2 to 4.4 behavior of resetting them, wrap the return in dash.remount.
- On the FastAPI backend, a before_request hook that returns a response now short-circuits the request, matching Flask.
- FastAPI's reload_dirs, reload_excludes, and reload_includes are now passed through to Uvicorn.
- If you tried streaming or shared storage in a 4.5 release candidate, pass shared_storage= or set DASH_SHARED_STORAGE. Shared storage is now opt-in, and streaming over HTTP needs it.
The full list of changes is in the changelog. Thanks to everyone who filed issues, tested pre-releases, and contributed code to this release.