📣 Dash 4.5.0 is out: streaming callbacks, shared storage, React 19 and faster renders

Hey everyone!

If you’ve ever tried to put an LLM in a Dash app, you know the pain. The model writes its answer token by token, but a callback returns one answer, all at once. So your user sits there watching a spinner until the whole thing is done. Same story for any job that runs for a minute and has progress it could be reporting.

Dash 4.5.0 fixes that. Callbacks can now stream: every yield reaches the browser as soon as it’s ready.

Getting that to work across tabs, workers and servers meant building something Dash never had before: shared storage, a way for every process behind an app to share state. And while we were in there, we added React 19 support and finally figured out why apps had been getting slower since 4.2.

This is the biggest Dash release since 4.0. Nineteen people from the community shipped features and fixes in it, and it took three release candidates (and a few changes of mind) to get here. Here’s what’s new, who built it, and what we learned along the way.

Streaming callbacks and shared storage

A streaming callback is just an async def generator. Each yield goes straight to the browser, and if you yield a dash.Patch, it gets applied incrementally. So a long job can fill a table row by row, or grow a chart point by point:

@callback(
    Output("log", "children"),
    Input("run", "n_clicks"),
    prevent_initial_call=True,
)
async def run_job(_):
    yield []
    async for line in job_output():
        patch = Patch()
        patch.append(html.Div(line))
        yield patch

Honestly, the API was the easy part. The transport was the hard part.

Browsers only allow about six open HTTP connections per host, so if every stream gets its own connection, the app freezes after a handful of streams. Our first few fixes treated symptoms, but what finally worked was a single downlink per browser, hosted in a SharedWorker. Every tab and every stream shares it, and closing a tab cancels that tab’s streams.

On the server, it has to work on both WSGI and ASGI:

  • Flask/gunicorn serves the downlink in poll mode. Holding a response open per browser pins a gunicorn thread, and the app hangs as soon as you have more browsers than threads.
  • FastAPI and Quart keep one open connection per browser and wait on asyncio futures instead of executor threads. The thread-pool version topped out around 500 browsers.

On an 8-core machine, one uvicorn worker held 1,000 streaming browsers at a 3 ms median. On gunicorn, plan for roughly 150 streaming browsers per worker. If you need thousands, use an ASGI backend.

All of this needs processes to share state: which browser owns which stream, which messages it has already seen, who’s still connected. That’s what shared storage is. You get it at dash.ctx.shared_storage: key/value with optional TTL, plus ordered, replayable pub/sub. It ships with three backends:

Backend Reach Use it for
LocalSharedStorage one container single-process apps, development
DiskcacheSharedStorage one machine multiple workers on one host
RedisSharedStorage many pods horizontally scaled deployments

Shared storage is opt-in (shared_storage= or DASH_SHARED_STORAGE). Streaming over HTTP requires it and will fail at startup without it. Streaming over WebSocket doesn’t need it.

React 19 (experimental)

Dash 4.5.0 can run on React 19.2.4. The default is still React 18.3.1. To opt in, set REACT_VERSION=19.2.4 or call dash._dash_renderer._set_react_version("19.2.4"). React 16 support is gone.

React 19 doesn’t publish the UMD builds Dash loads in the browser anymore, so we serve the community umd-react package instead. One gotcha: its version number doesn’t always match the React inside it. umd-react@19.2.0 actually contains React 19.1.0, and one of our earlier attempts shipped that without anyone noticing. We pinned 19.2.4, where the two agree.

The bigger risk is component libraries. A lot of them were built against React 18 or older and use APIs that React 19 removed. We added a small shim that loads right after React and before any component package, so those libraries keep working without a rebuild. If yours breaks on React 19, please open an issue!

Faster renders, and a way to keep them fast

Since 4.2.0, every component a callback returned as children was unmounted and mounted again on every update, even when only its props changed. That reset component state and made large subtrees 3 to 4 times slower. A related bug stopped components like dash-bootstrap-components Tabs from applying their initial state.

4.5.0 updates them in place again. If you actually want a fresh component, wrap it in the new dash.remount.

We fixed two more slowdowns while we were at it:

  • Pattern-matching callbacks (MATCH, ALL, ALLSMALLER) got quadratically slower as the number of matching components grew. A new id-to-path index cuts an ALL update over 400 components from about 580 ms to about 140 ms, with no changes to your app.
  • Pages that kept appending children with Patch got slower with every append. That’s fixed, and the layout crawl is about 16% faster too.

The regression shipped in 4.2.0 and stuck around through 4.4 because nothing was measuring it. So 4.5.0 adds a benchmark suite that runs on every pull request and fails it if a scenario gets more than twice as slow. CI machines vary, so each run is first scaled against a small reference render, and anything under 5 ms is ignored as timer noise.

Built by the community

Nineteen people outside Plotly wrote 29 of the pull requests in this release, and thirteen of them made their very first contribution to Dash this cycle! Several features in this post are theirs from start to finish: partial pattern matching, request compression, social meta tags and the WebSocket get_prop paths.

@AnnMarieW led the way with seven pull requests, from dcc.Graph cursor positions to bringing Markdown tables back after the dcc.Markdown rewrite. A lot of other folks showed up to fix the bug that was blocking their own app, and that’s how some of the hardest fixes landed: startup races, Windows paths, callbacks registered twice under a reloader.

Contributor What they shipped
AnnMarieW Social meta tags, dcc.Graph cursor pixel data, Markdown tables, dcc.Upload multi-file drop, number input steppers, date picker autocomplete
i-murray Partial pattern matching for callback IDs
datenzauberai Callback request compression
CNFeffery dcc.Link scrollToTop, partial WebSocket get_prop reads
dajiaohuang FastAPI reload options, background callback error handler fix, CI status fix
BSd3v FastAPI before_request responses, set_props in background callbacks
Aaron-Wrote-This Patch no longer re-runs callbacks or wipes persistence on existing components
mokashang Startup race under threaded workers
raashish1601 Callbacks registered twice under a reloader
jamalkamaladdin Extra callback registrations when run from a parent directory
0d00ciallo0721 Callbacks of removed components still firing
Mukller Dict IDs with a dot breaking the page
b84330808 Pages path variables with encoded characters
SulimanAbdulrazzaq Windows extended-length paths in JupyterLab Desktop
Sanjays2402 DASH_PROXY in Jupyter
antonymilne Component generation with spaces in the path
cyphercodes Backend detection in the testing runners
brandonschabell Removed pre-3.9 Python version checks
wleeaf WebSocket callback test before the first HTTP request

A big thank you to all who contributed! If you’d like to be on this list next time, issues labeled good first issue are a great place to start.

What we ran into

A release this size doesn’t go out in one shot. Here’s how the library evolved over each release.

Date Release What changed
Oct 29 4.5.0 Shared storage made opt-in; HTTP streaming requires it
Oct 23 rc2 Security hardening, plotly-cloud back to an optional extra
Oct 15 rc1 Multi-worker secrets and storage config, startup race fixes
Sep 21 rc0 Streaming, shared storage, React 19, render fixes

Scaling past one worker. Streaming worked on a laptop long before it worked on a cluster. On FastAPI with four workers and Redis, 24% to 57% of streams failed under load, because blocking Redis reads starved the worker’s thread pool. Switching to redis.asyncio with one reader per event loop got us to zero errors at 4,000 browsers. Workers also need to agree on a signing secret, so rc1 added DASH_SECRET_KEY and DASH_SHARED_STORAGE. That lets a hosting platform configure every worker without touching the app.

Startup races. Under threaded gunicorn workers, a request could show up while Dash was still registering callbacks and see a half-built app. The result was a burst of 500 errors on the first page load after a restart. A community fix in rc1 puts that setup behind a lock.

Changing our minds. rc0 made plotly-cloud a default dependency. rc2 moved it back to an extra, because it isn’t on conda-forge. Shared storage also started out on by default, and the final release makes it opt-in, so apps that don’t stream don’t pay anything for it.

A test suite we could trust. Our browser tests were pinned to Selenium 4.2, from 2022. It couldn’t drive current Chrome, and every pull request saw flaky failures. Unpinning it exposed more than a dozen tests that clicked and then read the page before the callback had finished. A few callback-count tests had been flaky for about two years: they assumed one callback per keystroke, but the renderer merges keystrokes that arrive together. Every test now waits for the settled state instead of retrying.

Everything else

  • Partial pattern matching: partial_pattern=True lets a dict ID pattern match components that have extra keys, and it works together with ALL and MATCH.
  • Request compression: compress_payload gzips large callback requests from the browser (5,000 bytes and up by default), on Flask, FastAPI and Quart.
  • Social meta tags: app.title, app.description and app.image now fill in the preview cards when someone shares a link to your app on social media.
  • Components: dcc.Markdown and dcc.Link have been rewritten in TypeScript, dcc.Link gets scrollToTop, and dcc.Graph click and hover data now include cursor pixel positions.
  • Backends: FastAPI and Quart run before_request hooks on every route and on the WebSocket handshake, just like Flask, and async def hook routes work everywhere.
  • Security: more href props block javascript: URLs, background callbacks without cache_by no longer share results between users, and the MCP server can cache per user.
  • Fixes: dcc.Upload with multiple dropped files, dcc.Input number steppers, Windows long paths in JupyterLab Desktop, and callbacks registered twice under uvicorn --reload.

The full changelog has everything.

Upgrading

pip install -U dash

Most apps will upgrade with no changes. There are three things worth checking:

  • If you pin React 16 with REACT_VERSION, move to 18.3.1 (the default) or give 19.2.4 a try.
  • If you were relying on callback-returned children being remounted to reset their state, wrap them in dash.remount.
  • To stream over HTTP, turn on shared storage with shared_storage= or DASH_SHARED_STORAGE. Persistent or multi-worker setups need dash[shared_storage], dash[diskcache] or dash[redis], plus DASH_SECRET_KEY.

A huge thank you to all 22 contributors, and to everyone who tested the release candidates and filed issues. I’m excited to see what you build with streaming callbacks, so please share it in a reply below! :rocket:

Great writeup … and more broadly thanks to all the contributor efforts here.

I’ll be keen to see/hear/read (and share/profile) about real-world use cases leveraging streaming callbacks and shared storage.

Don’t be shy.

Wow, great write-up, @mattbrown! It really tells the story behind the new features and bug fixes and gives a sense of how much work goes into each Dash release.

I’m especially excited about React 19 being included in this release. Dash Mantine Components needed it for our next major release, since Mantine 9 requires React 19.

I’ve been working with the Dash 4.5 development version and prereleases over the last few months, getting DMC ready for the upgrade. Look for the DMC v3 release tomorrow!