-
dash/dash.py- MainDashapplication class (~2000 lines). Orchestrates the server backend, layout management, callback registration, routing, and asset serving. Key methods:layoutproperty,callback(),clientside_callback(),run(). -
dash/backends/- Server backend implementations. See Server Backends section for details. -
dash/_callback.py- Callback registration and execution. Containscallback()decorator (usable as@dash.callbackwithout app instance),clientside_callback(), andregister_callback()which inserts callbacks into the callback map. -
dash/dependencies.py- Dependency classes for callbacks:Input- Triggers callback when value changesOutput- Component property to update (supportsallow_duplicate=True)State- Read value without triggering callbackClientsideFunction- Reference to JS function for clientside callbacks- Wildcards:
MATCH,ALL,ALLSMALLERfor pattern-matching IDs
-
dash/development/base_component.py-Componentbase class withComponentMetametaclass. All Dash components inherit from this. Components auto-register inComponentRegistryand serialize to JSON viato_plotly_json(). -
dash/_pages.py- Multi-page app support.PAGE_REGISTRYholds registered pages,register_page()decorator registers page modules with routes.
The layout defines the UI as a tree of components:
app.layout = html.Div([
dcc.Input(id='input', value='initial'),
html.Div(id='output')
])- Static layout: Assigned directly as a component tree
- Dynamic layout: Assigned as a function that returns components (called on each page load, useful for per-session state)
- Layout is serialized to JSON and sent to the React frontend via
/_dash-layout - Components can contain other components via
childrenprop - Component IDs can be strings or dicts (for pattern-matching callbacks)
@app.callback or @dash.callback:
@app.callback(Output('output', 'children'), Input('input', 'value'))
def update(value):
return f'You entered: {value}'Server-side Python function called when inputs change. Outputs update component properties.
app.clientside_callback:
app.clientside_callback(
"""function(value) { return 'You entered: ' + value; }""",
Output('output', 'children'),
Input('input', 'value')
)JavaScript function runs in browser. Faster for simple transformations, no server round-trip. Can reference window.dash_clientside.namespace.function_name or inline JS string.
background=True:
@app.callback(Output('output', 'children'), Input('btn', 'n_clicks'),
background=True, manager=diskcache_manager,
running=[(Output('btn', 'disabled'), True, False)],
progress=[Output('progress', 'value')])
def compute(set_progress, n_clicks):
for i in range(10):
set_progress(i * 10)
time.sleep(1)
return 'Done'Callbacks executed in separate process via Celery or Diskcache manager. Supports progress updates, running state changes, and cancel inputs. See Background Callbacks section for details.
@app.callback(
Output({'type': 'output', 'index': MATCH}, 'children'),
Input({'type': 'input', 'index': MATCH}, 'value')
)
def update(value):
return valueUse dict IDs with wildcards (MATCH, ALL, ALLSMALLER) to target dynamically-generated components.
/_dash-layout- Returns initial component tree as JSON/_dash-dependencies- Returns callback definitions/_dash-update-component- Executes callbacks, returns updated props/_dash-component-suites/<package>/<path>- Serves component JS/CSS assets/assets/<path>- Serves static assets from app's assets folder
Dash supports multiple web server backends. The backend abstraction is in dash/backends/.
| Backend | Type | Install | Use Case |
|---|---|---|---|
| Flask (default) | WSGI (sync) | pip install dash |
Standard deployments, simplicity |
| Quart | ASGI (async) | pip install dash[quart] |
Async callbacks, WebSocket support |
| FastAPI | ASGI (async) | pip install dash[fastapi] |
OpenAPI docs, async, modern Python |
Default (Flask):
from dash import Dash
app = Dash(__name__)With existing server instance:
from flask import Flask
from dash import Dash
server = Flask(__name__)
app = Dash(__name__, server=server)Quart backend:
from quart import Quart
from dash import Dash
server = Quart(__name__)
app = Dash(__name__, server=server)FastAPI backend:
from fastapi import FastAPI
from dash import Dash
server = FastAPI()
app = Dash(__name__, server=server)
# Run with: uvicorn module:app.server --reloadThe backend system uses an abstract interface:
-
BaseDashServer(dash/backends/base_server.py) - Abstract base class defining the server interface. All backends implement this. -
RequestAdapter- Normalizes HTTP request objects across frameworks. Provides unified access toargs,cookies,headers,get_json(), etc. -
ResponseAdapter- Normalizes response creation. Handlesset_cookie(),set_header(),set_response(). -
get_backend(name)- Factory function to get backend class by name ("flask","quart","fastapi"). -
get_server_type(server)- Auto-detects backend from a server instance.
Flask (dash/backends/_flask.py):
FlaskDashServer- Wraps Flask appFlaskRequestAdapter- Usesflask.requestproxyFlaskResponseAdapter- Usesflask.Response- Compression via
flask-compress
Quart (dash/backends/_quart.py):
QuartDashServer- Wraps Quart app (async Flask API)QuartRequestAdapter- Usesquart.requestproxyQuartResponseAdapter- Usesquart.Response- All route handlers are
async def - Compression via
quart-compress
FastAPI (dash/backends/_fastapi.py):
FastAPIDashServer- Wraps FastAPI appFastAPIRequestAdapter- Uses context variable for current requestFastAPIResponseAdapter- Uses Starlette responsesDashMiddleware- Consolidated ASGI middleware for request handling- Runs with uvicorn, supports hot reload
- Built-in GZip compression
All backends implement:
class BaseDashServer(ABC):
def create_app(name, config) -> server # Create new server
def add_url_rule(rule, view_func, ...) # Register routes
def before_request(func) # Request hooks
def after_request(func) # Response hooks
def run(dash_app, host, port, debug) # Start dev server
def make_response(data, mimetype, status) # Create response
def jsonify(obj) # JSON response
def setup_index(dash_app) # Register / route
def serve_callback(dash_app) # Callback endpoint
def setup_component_suites(dash_app) # JS/CSS servingapp = Dash(__name__)
# Get the underlying server
app.server # Flask/Quart/FastAPI instance
# Get the backend wrapper
app.backend # BaseDashServer subclass instance
app.backend.server_type # "flask", "quart", or "fastapi"
# Access request in callbacks
from dash import dash
dash.get_app().backend.request_adapter() # RequestAdapter instancedash/dash-renderer/src/ contains the TypeScript/React frontend. See RENDERER.md for detailed documentation on:
- Layout traversal (
crawlLayout) andchildren_props - Component resolution from
window[namespace][type] - Callback triggering via
setPropsandnotifyObservers - Redux store structure (layout, paths, callbacks, graphs)
- Observer system for callback processing
window.dash_clientsideAPIwindow.dash_component_apiAPI
Dash supports multiple React versions. Configured in dash/_dash_renderer.py.
Available versions: 18.3.1 (default), 18.2.0, 19.2.4 (experimental)
React 19 has no official UMD builds; Dash serves the umd-react package for it, plus a small shim (dash-renderer/build/react-shim.min.js, source dash/dash-renderer/src/react-shim.js) loaded right after react-dom and before any component package. The shim stubs the React <=18 secret internals (ReactCurrentOwner) some component libraries touch at load time, redirects the legacy react.element $$typeof symbol so libraries that pre-bundled a React <=18 jsx-runtime don't hit React error #525, and provides window.ReactJSXRuntime, the global that component bundles externalize react/jsx-runtime to.
Convention for component libraries: externalize react/jsx-runtime and react/jsx-dev-runtime using the defensive external expression found in components/dash-core-components/webpack.config.js (jsxRuntimeExternal), not a bare 'ReactJSXRuntime' string. The expression falls back to building the runtime from window.React.createElement when the global is missing, so the same bundle works on Dash versions that predate the shim. A bare 'ReactJSXRuntime' external throws ReactJSXRuntime is not defined / Cannot read properties of undefined (reading 'jsx') at bundle load on older Dash.
Set via environment variable (experimental):
REACT_VERSION=19.2.4 python app.pyOr programmatically before creating the app:
from dash._dash_renderer import _set_react_version
_set_react_version("19.2.4")
from dash import Dash
app = Dash(__name__)This is useful for compatibility with older component libraries that require React 16.
Multi-page apps use dash/_pages.py with automatic routing via dcc.Location.
Each page module calls register_page():
# pages/analytics.py
from dash import register_page, html
register_page(__name__) # infers path /analytics from module name
layout = html.Div("Analytics page")PAGE_REGISTRY-OrderedDictstoring all registered pages with metadataregister_page(module, path=None, ...)- Registers page with inferred or explicit path, title, description, image
When use_pages=True, Dash injects page_container as the layout (dash/dash.py:148-158):
page_container = html.Div([
dcc.Location(id="_pages_location", refresh="callback-nav"),
html.Div(id="_pages_content"), # current page layout injected here
dcc.Store(id="_pages_store"), # stores page title/metadata
])dcc.Locationtracks browser URL changes- Internal callback listens to
pathnameandsearchinputs _path_to_page()matches URL to registered page inPAGE_REGISTRY- Page layout injected into
_pages_contentdiv
Pages can capture URL variables:
register_page(__name__, path_template="/asset/<asset_id>")
def layout(asset_id=None):
return html.Div(f"Asset: {asset_id}")_parse_path_variables() extracts variables via regex and passes them as kwargs to the layout function.
_import_layouts_from_pages() walks the pages/ folder:
- Skips files starting with
_or. - Only imports
.pyfiles containingregister_page - Auto-assigns
layoutattribute from each module to the registry
Pages sorted by: numeric order → string order → no order → module name. Home page (/) defaults to order 0.
The assets/ folder is automatically scanned at startup (dash/dash.py:_walk_assets_directory):
.cssfiles → appended to stylesheets.jsfiles → appended to scriptsfavicon.ico→ used as app favicon- Files matching
assets_ignoreregex are skipped
Resources load in this order (dash/dash.py:1127-1165):
- React dependencies (from dash-renderer)
- Component library scripts (dash-html-components, dash-core-components, etc.)
- External scripts (
external_scriptsparameter) - Dash renderer bundle
- Clientside callback scripts (inline)
CSS follows similar ordering with external stylesheets first.
Component assets use fingerprinted URLs for cache busting (dash/fingerprint.py):
/_dash-component-suites/dash_core_components/dash_core_components.v2_14_0m1699900000.min.js
- Fingerprinted resources: 1-year cache header
- Non-fingerprinted: ETag validation
- Asset files: query string
?m={modification_time}
Dash(
assets_folder='assets', # path to assets directory
assets_url_path='assets', # URL path segment
assets_ignore='.*ignored.*', # regex to skip files
assets_external_path=None, # CDN base URL for assets
serve_locally=True, # True=local files, False=CDN
external_scripts=[], # additional JS URLs
external_stylesheets=[], # additional CSS URLs
)app.get_asset_url(path) returns the correct URL accounting for requests_pathname_prefix (important for Dash Enterprise deployments where apps have URL prefixes).
Debug mode enables developer tools (dash/dash.py:_setup_dev_tools):
app.run(debug=True)
# Or via environment: DASH_DEBUG=trueapp.enable_dev_tools(
dev_tools_ui=True, # show error UI overlay
dev_tools_props_check=True, # validate component prop types
dev_tools_serve_dev_bundles=True, # use development JS (better errors)
dev_tools_hot_reload=True, # auto-reload on file changes
dev_tools_prune_errors=True, # strip internal frames from tracebacks
)Environment variables: DASH_DEBUG, DASH_UI, DASH_PROPS_CHECK, DASH_HOT_RELOAD, etc.
PreventUpdate - Skip updating outputs without error:
from dash.exceptions import PreventUpdate
@app.callback(Output('out', 'children'), Input('in', 'value'))
def update(value):
if not value:
raise PreventUpdate
return valueno_update - Skip specific outputs in multi-output callbacks:
from dash import no_update
@app.callback(Output('a', 'children'), Output('b', 'children'), Input('in', 'value'))
def update(value):
return value, no_update # only updates 'a'Callbacks support on_error for custom error handling:
def handle_error(err):
logging.error(f"Callback failed: {err}")
return "Error occurred" # returned to output
@app.callback(Output('out', 'children'), Input('in', 'value'), on_error=handle_error)
def update(value):
return 1 / 0 # triggers error handlerApp-level error handler set via constructor.
- Layout validation: When
suppress_callback_exceptions=False(default), checks that callback IDs exist in layout - Callback validation:
dev_tools_validate_callbacks=Truechecks for circular dependencies - Props checking: Validates component prop types against schema in dev mode
When enabled, a watch thread monitors:
assets/folder for CSS/JS changes- Component package directories
Frontend polls /_reload-hash and triggers reload when hash changes. Configurable via hot_reload_interval (default 3s) and hot_reload_watch_interval (default 0.5s).
Background callbacks execute in separate processes, allowing the main server to remain responsive. Managed by dash/background_callback/managers/.
from dash import callback, Input, Output
from dash.background_callback import DiskcacheManager
cache_manager = DiskcacheManager()
@callback(
Output("result", "children"),
Input("button", "n_clicks"),
background=True,
manager=cache_manager,
interval=500, # polling interval in ms
)
def compute(n_clicks):
# Expensive computation
return resultDiskcacheManager (dash/background_callback/managers/diskcache_manager.py):
- Uses
diskcache.Cachefor persistent storage - Spawns
multiprocess.Processfor each job - Results stored on disk, survives server restarts
- Good for single-server deployments
CeleryManager (dash/background_callback/managers/celery_manager.py):
- Requires Celery app with result backend (Redis/RabbitMQ)
- Jobs distributed across Celery workers
- Supports horizontal scaling
- Good for production multi-worker deployments
from celery import Celery
from dash.background_callback import CeleryManager
celery_app = Celery(__name__, broker="redis://localhost:6379/0")
cache_manager = CeleryManager(celery_app)The progress parameter defines outputs updated during execution:
@callback(
Output("result", "children"),
Input("button", "n_clicks"),
progress=Output("progress-bar", "value"),
progress_default=0,
background=True,
manager=cache_manager,
)
def compute(set_progress, n_clicks):
for i in range(100):
set_progress(i)
time.sleep(0.1)
return "Complete"set_progressis injected as first argument whenprogressis specified- Can be single Output or list of Outputs
progress_defaultsets value when callback not running
The running parameter updates outputs while the job executes:
@callback(
Output("result", "children"),
Input("button", "n_clicks"),
running=[
(Output("button", "disabled"), True, False),
(Output("status", "children"), "Computing...", "Ready"),
],
background=True,
manager=cache_manager,
)
def compute(n_clicks):
time.sleep(5)
return "Done"Each tuple: (Output, value_while_running, value_when_complete)
The cancel parameter specifies inputs that abort the job:
@callback(
Output("result", "children"),
Input("start-btn", "n_clicks"),
cancel=[Input("cancel-btn", "n_clicks")],
background=True,
manager=cache_manager,
)
def compute(n_clicks):
# Job terminates if cancel-btn clicked
return resultManagers call terminate_job() which kills the process (Diskcache) or revokes the task (Celery).
Results can be cached to avoid recomputation:
def get_user_id():
return flask.session.get("user_id")
cache_manager = DiskcacheManager(
cache_by=[get_user_id], # cache key includes user ID
expire=3600, # TTL in seconds
)cache_by- List of functions whose return values are included in cache keyexpire- Time-to-live for cached resultscache_args_to_ignore- Argument indices to exclude from cache key
- Initial request: Frontend triggers callback, backend returns
cacheKeyandjobID - Polling: Frontend polls
/_dash-update-component?cacheKey=...&job=...at configured interval - Progress: Each poll returns current progress value if set
- Completion: When job finishes, poll returns final result
- Cleanup: Results cleared from cache (unless
cache_byspecified)
Cache key is SHA256 hash of: function source + arguments + triggered inputs + cache_by values.
dash/_callback.py:188-219- Background spec constructiondash/background_callback/managers/__init__.py-BaseBackgroundCallbackManagerabstract classdash/background_callback/managers/diskcache_manager.py- Diskcache implementationdash/background_callback/managers/celery_manager.py- Celery implementationdash/dash-renderer/src/actions/callbacks.ts:458-685- Frontend polling logic
Dash apps can run directly in Jupyter notebooks and JupyterLab. The integration is handled by dash/_jupyter.py.
app.run(
jupyter_mode="inline", # Display in notebook cell (default)
jupyter_width="100%", # IFrame width
jupyter_height=650, # IFrame height in pixels
)| Mode | Behavior |
|---|---|
"inline" |
App displays in notebook cell via IFrame |
"external" |
Prints URL, user opens in browser tab |
"jupyterlab" |
Opens in dedicated JupyterLab tab |
"tab" |
Auto-opens URL in new browser tab |
app.run()detects Jupyter environment viaget_ipython()- Server starts in background daemon thread
- Jupyter comm protocol negotiates proxy configuration
- App displays according to selected mode
app.run() in notebook
↓
Detect Jupyter → Start server in background thread
↓
Comm request → Extension responds with base_url
↓
Compute dashboard URL with proxy path
↓
Display: IFrame (inline) / URL (external) / Tab (jupyterlab)
Classic Jupyter notebooks use dash/nbextension/:
main.js- Registers "dash" comm targetdash.json- Extension loader configuration
The extension handles comm messages:
base_url_request→ responds with server URL and base path- Enables proper proxy routing in JupyterHub environments
JupyterLab uses @plotly/dash-jupyterlab/:
src/index.ts- TypeScript plugin implementingJupyterFrontEndPluginDashIFrameWidget- Lumino widget for rendering apps in tabs
Handles messages:
base_url_request→ responds with JupyterLab server configshow→ creates dedicated tab with IFrame widget
Compatible with JupyterLab 2.x, 3.x, and 4.x.
In JupyterHub/proxy environments, the extension negotiates requests_pathname_prefix:
# Computed from Jupyter base path
requests_pathname_prefix = "/user/username/proxy/8050/"This ensures callbacks route correctly through the Jupyter proxy.
Special handling for Colab:
- Uses
google.colab.output.serve_kernel_port_as_iframe()for inline - Uses
google.colab.output.serve_kernel_port_as_window()for external - Only supports "inline" and "external" modes
dash/_jupyter.py-JupyterDashclass, comm handling, server threaddash/nbextension/main.js- Classic notebook extension@plotly/dash-jupyterlab/src/index.ts- JupyterLab extension
Basic Setup:
name- Application name (default: infers from__name__)server- Server instance (Flask, Quart, or FastAPI) orTrueto create Flask (default:True)title- Browser tab title (default:"Dash")update_title- Title during callbacks (default:"Updating...")
Assets & Resources:
assets_folder- Path to assets directory (default:"assets")assets_url_path- URL path for assets (default:"assets")assets_ignore- Regex to exclude assets (default:"")serve_locally- Serve from local vs CDN (default:True)external_scripts- Additional JS URLsexternal_stylesheets- Additional CSS URLs
Routing:
url_base_pathname- Base URL prefix for entire apprequests_pathname_prefix- Prefix for AJAX requestsroutes_pathname_prefix- Prefix for API routes
Multi-Page:
use_pages- Enable pages system (default: auto-detect)pages_folder- Path to pages directory (default:"pages")
Behavior:
suppress_callback_exceptions- Skip callback validation (default:False)prevent_initial_callbacks- Skip callbacks on load (default:False)background_callback_manager- DiskcacheManager or CeleryManageron_error- Global callback error handlershared_storage- Cross-process state + pub/sub backend (default:LocalSharedStorage). PassNoneto disable, or aBaseSharedStoragesubclass/instance to swap. See Shared Storage.
WebSocket Callbacks:
websocket_callbacks- Enable WebSocket for all callbacks (default:False). Requires FastAPI backend.websocket_allowed_origins- List of allowed origins for WebSocket connectionswebsocket_inactivity_timeout- Disconnect WebSocket after inactivity period in ms (default:300000= 5 minutes). Set to0to disable.
host- Server IP (default:"127.0.0.1", env:HOST)port- Server port (default:8050, env:PORT)debug- Enable dev tools (default:False, env:DASH_DEBUG)jupyter_mode- Display mode:"inline","external","tab"
| Variable | Purpose |
|---|---|
DASH_DEBUG |
Enable debug mode |
DASH_URL_BASE_PATHNAME |
Base URL prefix |
DASH_SUPPRESS_CALLBACK_EXCEPTIONS |
Skip validation |
DASH_HOT_RELOAD |
Enable hot reload |
DASH_PROPS_CHECK |
Validate prop types |
DASH_PRUNE_ERRORS |
Simplify tracebacks |
HOST |
Server host |
PORT |
Server port |
Store data client-side with configurable persistence:
dcc.Store(id='my-store', storage_type='local', data={'key': 'value'})| Storage Type | Persists | Scope | Use Case |
|---|---|---|---|
'memory' |
Page view only | Tab | Temporary state, debugging |
'session' |
Browser session | Tab | Form state, filters |
'local' |
Forever | All tabs | User preferences, settings |
Usage pattern:
@app.callback(Output('output', 'children'), Input('store', 'data'))
def use_store(data):
return data['key']
@app.callback(Output('store', 'data'), Input('input', 'value'))
def update_store(value):
return {'key': value}Automatically persist user edits to component props:
dcc.Dropdown(
id='dropdown',
options=[...],
persistence=True, # Enable persistence
persistence_type='local', # local, session, or memory
persisted_props=['value'], # Props to persist (default varies by component)
)persistence-Trueor unique key to enablepersistence_type- Storage backend (default:'local')persisted_props- List of prop names to persist
Supported components: Input, Dropdown, Checklist, RadioItems, Slider, RangeSlider, DatePickerSingle, DatePickerRange, Textarea, Tabs, DataTable.
| Need | Solution |
|---|---|
| Server-controlled state | dcc.Store with callbacks |
| Remember user selections | Component persistence=True |
| Share state across tabs | dcc.Store with storage_type='local' |
| Session-only state | persistence_type='session' |
Backend-agnostic server-side state shared across worker processes: a
cross-process key/value store plus an ordered, replayable publish/subscribe
channel. Unlike dcc.Store (which lives in the browser), shared storage lives
on the server and lets callbacks running in different workers see the same keys
and topics without standing up an external service like Redis.
Enabled by default on every app. It starts lazily on first use, so it costs nothing until touched.
import dash
from dash import Input, Output, callback
@callback(Output("out", "children"), Input("btn", "n_clicks"))
def handler(n):
store = dash.ctx.shared_storage # inside a callback
store.set("clicks", n)
return store.get("clicks", 0)
# Or off the app directly:
app = dash.Dash()
app.shared_storage.set("key", {"any": "json-compatible value"})Values (and published messages) must be JSON-compatible — dict / list / str
/ int / float / bool / None — the same constraint as dcc.Store and callback
outputs. Messages are encoded with msgspec (msgpack), a hard dependency.
app.shared_storage (and dash.ctx.shared_storage) is a BaseSharedStorage:
| Method | Purpose |
|---|---|
get(key, default=None) |
Read a value |
set(key, value, ttl=None) |
Write a value; ttl = optional lifetime in seconds |
delete(key) |
Remove a key |
publish(topic, message, ttl=None) |
Append a message to a topic; ttl = release the topic after that long idle |
subscribe(topic, replay_from=None) |
Return a Subscription |
Key expiry (TTL). set(key, value, ttl=<seconds>) gives a key a bounded
lifetime; once it elapses, reads return the default again. ttl=None (the
default) never expires. Expiry is lazy/best-effort — an expired key is dropped
on its next read, not at a guaranteed instant. Each backend uses its native
mechanism: a monotonic deadline on the in-memory owner, diskcache expire,
Redis PX. Use it for session-scoped or cache-like state you don't want to
accumulate unboundedly.
Ordered pub/sub. Each publish to a topic gets a monotonically increasing
sequence number (per topic, starting at 1; 0 means "before the first
message"). A subscriber receives every message published after it subscribed,
in order:
# Producer (one worker):
store.publish("progress", {"pct": 50})
# Consumer (another worker/callback):
with store.subscribe("progress") as sub:
for message in sub: # or: async for message in sub
print(message["pct"])Replay on reconnect. Pass replay_from=<last-seen-seq> to resume after a
drop; buffered messages since that cursor replay first, so a reconnecting
consumer doesn't miss messages. If the consumer fell farther behind than the
bounded buffer holds, the subscription raises SharedStorageGap (an explicit
gap rather than a silent hole). A Subscription is iterable synchronously
(for) or asynchronously (async for), is a context manager, and has
close().
Three backends ship, differing only in how far state reaches. Pick by the deployment topology — critically, whether the app runs behind a load balancer as more than one container/pod (see Deployment Topology below).
| Backend | Backing store | Reaches | Extra |
|---|---|---|---|
LocalSharedStorage (default) |
in-memory, owner-elected socket | one container (all its workers) | none |
DiskcacheSharedStorage |
a diskcache.Cache |
one host (all processes sharing the dir) | dash[diskcache] |
RedisSharedStorage |
Redis (Streams for pub/sub) | any process/container/pod | dash[redis] |
All three implement the same BaseSharedStorage contract (KV + ordered,
replayable pub/sub with SharedStorageGap on buffer overrun), so app code is
identical across them — only the constructor differs.
from dash import Dash
from dash._shared_storage import (
LocalSharedStorage, DiskcacheSharedStorage, RedisSharedStorage,
)
Dash(shared_storage=LocalSharedStorage) # default
Dash(shared_storage=DiskcacheSharedStorage(directory="/tmp/ss"))
Dash(shared_storage=RedisSharedStorage(url="redis://host:6379"))
Dash(shared_storage=None) # disabledWhen disabled, accessing app.shared_storage / dash.ctx.shared_storage
raises SharedStorageError.
LocalSharedStorage — in-memory, no external service. On first use every
worker races to become the single owner for the machine by binding a stable
address (AF_UNIX on POSIX, 127.0.0.1 loopback on Windows); the winner hosts
the engine, losers proxy over the socket. The bind is the lease — when the
owner dies the address frees and a survivor re-elects. A single-process
deployment is its own owner and pays no socket overhead. Knobs: namespace
(defaults to a hash of cwd + argv[0]), buffer_size (default 32 — small on
purpose, since each topic retains that many arbitrary payloads; raise it for a
wider reconnect window).
Topic lifetime. publish(topic, message, ttl=None) takes an optional ttl
(seconds, at least MIN_TOPIC_TTL = 1s, so a reconnecting reader is not
outrun). A topic with a ttl is released, buffer and sequence both, once nobody
has published to or read from it for that long; a later publish starts it over
at 1, so a consumer returning with an old cursor gets SharedStorageGap.
Without a ttl a topic lives as long as the store. The latest publish's ttl
wins. Streaming publishes with STREAM_TOPIC_TTL (300s); user topics default
to no ttl. Every backend expires a topic as a whole, never message by message
while it is read (tests/shared_storage/test_topic_ttl.py runs the same cases
on all three). Local: the engine records the ttl on the topic and sweeps idle
ones (no publish, head or poll, and no call holding it) at most once a second,
on the next pub/sub call. The sweep also drops empty, unheld topics, such as
the one a returning reader's poll recreates after a release. Redis: the ttl sits in a third key; the publish
script PEXPIREs all three to 1.25 ttl, and a poll script renews them. A poll
on such a topic blocks in XREAD for at most a third of that lifetime, not the
usual 5s, so a waiting reader renews it in time. Diskcache: the ttl
sits in a key; on publish or poll, once less than one ttl is left, every key of
the topic (counter, ttl, buffered messages) is renewed to 1.25 ttl, and at
once when a publish changes the ttl. A poll on such a topic waits at most half
a ttl before it renews again.
Durability is controlled by mode (the key/value store only — pub/sub is
always transient):
mode |
Writes | On owner death / restart |
|---|---|---|
"memory" (default) |
none | re-elects cold (state lost) |
"persist" |
write-through on every set/delete | re-elected/fresh owner recovers from disk |
"persist-reset" |
flushed every flush_intervals (default 60) + on clean exit |
recovers to the last snapshot (up to one interval may be lost on an unclean crash) |
Only the owner persists. The on-disk store is chunked (keys sharded across
msgpack chunk files via a key→chunk index, so a mutation rewrites only the
affected chunk — adapted from raposa's BinaryStorage); each chunk write is
atomic (temp file + os.replace). TTLs survive restarts (stored as wall-clock
deadlines; expired keys are dropped on load). path sets the store directory
(default: a per-namespace folder under the user cache dir). Recovery runs on
every election, so persisted data also survives owner re-election:
Dash(shared_storage=LocalSharedStorage(mode="persist"))
Dash(shared_storage=LocalSharedStorage(mode="persist-reset", flush_interval=30))DiskcacheSharedStorage — KV + an append-log pub/sub on a diskcache.Cache,
the same store DiskcacheManager uses; pass an existing cache to share one.
Every process on one host that opens the same directory shares state, so it
gives multi-worker parity with the local backend without a socket. Not for
pods — each pod has its own ephemeral disk.
RedisSharedStorage — KV on Redis strings, pub/sub on a Redis Stream per
topic (an atomic INCR+XADD script keeps sequences ordered; MAXLEN bounds
the replay window; a subscriber past the trimmed floor gets SharedStorageGap).
Redis is the single source of truth, so no owner election. Pass a url
(defaults to $REDIS_URL) or an existing client to reuse a connection pool.
This is the only backend correct for horizontally-scaled, multi-pod
deployments.
The default LocalSharedStorage is per-container: its socket/loopback
election only reaches processes in the same network + filesystem namespace.
- Single process / single pod (e.g. Plotly Cloud apps): fine — one owner, nothing to fragment.
- Multiple gunicorn workers in one container: fine — they share the pod's loopback/socket and elect one owner.
- Multiple pods behind a load balancer (e.g. Dash Enterprise apps scaled by
an HPA, routed round-robin with no session affinity): each pod elects its own
isolated owner, so
set()/publish()on one pod are invisible on another. This works at 1 pod and fragments silently once it scales — useRedisSharedStorage(one Redis shared by all pods) instead.
BaseSharedStorage is the stable, public extension point. A backend — shipped
in-tree or as a separate package — implements:
get(key, default)/set(key, value)/delete(key)— JSON-compatible valuespublish(topic, message, ttl=None)andsubscribe(topic, replay_from=None) -> Subscription- topic expiry: with a
ttl, release the topic as a whole once nobody has published to or read from it for that long, never message by message while it is read; reject a ttl underMIN_TOPIC_TTL(runtest_topic_ttl.pyagainst a new backend) - optional
start()/close()(idempotent, called once per worker)
and returns a Subscription (__iter__ / __aiter__ / close) that raises
SharedStorageGap when the replay buffer overran. That trio —
BaseSharedStorage, Subscription, SharedStorageGap — is exported from
top-level dash; the poll-loop helpers (PollResult, the polling subscription)
are private and not part of the contract.
Core only ships backends whose dependency is already a Dash extra (msgspec
base; dash[diskcache]; dash[redis]). Anything needing a heavier dependency
belongs out of tree behind this same interface — e.g. a Postgres backend
(LISTEN/NOTIFY for push pub/sub + a table for KV and replay) lives in its own
package so a psycopg connection is never pulled into Dash core. It plugs in the
same way as a built-in: Dash(shared_storage=PostgresSharedStorage(...)).
| File | Responsibility |
|---|---|
_shared_storage/base.py |
Abstract interface: BaseSharedStorage, Subscription, SharedStorageError, SharedStorageGap |
_shared_storage/_engine.py |
StoreEngine: authoritative in-memory kv map + per-topic ordered log with bounded replay buffer (thread-safe, transport-agnostic) |
_shared_storage/local.py |
LocalSharedStorage: owner election; owner hosts the engine, clients proxy |
_shared_storage/diskcache.py |
DiskcacheSharedStorage: KV + append-log pub/sub on a diskcache.Cache |
_shared_storage/redis.py |
RedisSharedStorage: KV + Redis Streams pub/sub (atomic INCR+XADD) |
_shared_storage/_polling.py |
PollingSubscription: shared poll-loop subscription for the diskcache/Redis backends |
_shared_storage/_transport.py |
Length-prefixed, token-gated socket transport (local backend) |
_shared_storage/_codec.py |
msgspec msgpack codec (data-only) |
dash.py |
shared_storage constructor arg + lazy app.shared_storage property |
_callback_context.py |
dash.ctx.shared_storage accessor |
Requires msgspec (in requirements/install.txt); DiskcacheSharedStorage
needs the diskcache extra and RedisSharedStorage the redis extra.
Dash supports async def callbacks for non-blocking execution.
With Flask backend:
pip install dash[async]Async is auto-enabled when asgiref is detected. Or explicitly:
app = Dash(__name__, use_async=True)With Quart or FastAPI backend: Async is native - no extra dependencies needed.
from fastapi import FastAPI
from dash import Dash
server = FastAPI()
app = Dash(__name__, server=server) # Async works automaticallyimport asyncio
@app.callback(Output('output', 'children'), Input('input', 'value'))
async def async_update(value):
await asyncio.sleep(1) # Non-blocking
return f"Processed: {value}"- Regular async callbacks are non-blocking - multiple can run concurrently
- Background callbacks also support
async def - Jupyter uses
nest_asynciofor event loop compatibility - With Flask backend: requires
dash[async], coroutines raise error without it - With Quart/FastAPI backends: async is native, no extra setup needed
@app.callback(
Output('result', 'children'),
Input('btn', 'n_clicks'),
background=True,
manager=diskcache_manager,
)
async def async_background(n_clicks):
await asyncio.sleep(5)
return "Done"Both DiskcacheManager and CeleryManager support async functions via asyncio.run().
WebSocket callbacks use a persistent WebSocket connection instead of HTTP POST for callback execution. This reduces latency and connection overhead for applications with frequent callbacks.
- FastAPI backend required: WebSocket callbacks only work with FastAPI
- SharedWorker support: Modern browsers (not IE)
Enable globally for all callbacks:
from fastapi import FastAPI
from dash import Dash
server = FastAPI()
app = Dash(__name__, server=server, websocket_callbacks=True)Enable per-callback:
@app.callback(
Output('output', 'children'),
Input('input', 'value'),
websocket=True # Use WebSocket for this callback only
)
def update(value):
return f"Value: {value}"app = Dash(
__name__,
server=server,
websocket_callbacks=True,
websocket_inactivity_timeout=300000, # 5 minutes (default)
websocket_heartbeat_interval=30000, # 30 seconds (default)
websocket_allowed_origins=['https://example.com'],
)websocket_callbacks- Enable WebSocket for all callbacks (default:False)websocket_inactivity_timeout- Close WebSocket after period of inactivity in milliseconds (default:300000= 5 minutes). Heartbeats do not count as activity. Set to0to disable timeout. Connection automatically reconnects when needed.websocket_heartbeat_interval- Interval for heartbeat/keep-alive checks in milliseconds (default:30000= 30 seconds). Also determines how frequently inactivity timeout is checked.websocket_allowed_origins- List of allowed origins for WebSocket connections (security)
┌─────────────────────────────────────────────────────────────────────────┐
│ Browser Tab 1 Browser Tab 2 │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Renderer │ │ Renderer │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ │ postMessage │ postMessage │
│ └────────────┬───────────────────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ SharedWorker │ (one per origin) │
│ │ dash-ws-worker │ │
│ └──────────┬──────────┘ │
└────────────────────│────────────────────────────────────────────────────┘
│ WebSocket
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Server (FastAPI) │
│ WebSocket Endpoint: /_dash-ws-callback │
└─────────────────────────────────────────────────────────────────────────┘
Connection & Reconnection Flow:
Renderer SharedWorker Server
│ │ │
│──[CONNECT]──────────────────>│ │
│ │──[WebSocket Connect]──>│
│<─[CONNECTED]─────────────────│<─[Connected]───────────│
│ │ │
│──[CALLBACK_REQUEST]─────────>│──[callback request]───>│
│<─[CALLBACK_RESPONSE]─────────│<─[callback response]───│
│ │ │
│ (inactivity) │ (heartbeat check) │
│ │──[close 4001]─────────>│
│<─[DISCONNECTED]──────────────│ │
│ │ │
│──[CALLBACK_REQUEST]─────────>│──[reconnect + send]───>│
│<─[CALLBACK_RESPONSE]─────────│<─[response]────────────│
- SharedWorker: Single WebSocket connection shared across browser tabs
- Heartbeat: Periodic ping/pong to detect dead connections (30s interval)
- Inactivity timeout: Closes connection after no actual callback activity (not heartbeats)
- Auto-reconnect: Reconnects automatically when a callback is triggered after timeout
WebSocket callbacks can stream updates to the client during execution using set_props() and read current component values using ctx.websocket:
import asyncio
from dash import callback, Output, Input, set_props, ctx
from dash.exceptions import PreventUpdate
@callback(
Output('result', 'children'),
Input('start-btn', 'n_clicks'),
prevent_initial_call=True
)
async def long_running_task(n_clicks):
ws = ctx.websocket
if not ws:
return "WebSocket not available"
# Stream progress updates to the client
for i in range(100):
# IMPORTANT: Check is_shutdown in loops to detect disconnections
if ws.is_shutdown:
raise PreventUpdate # Exit gracefully on disconnect
await asyncio.sleep(0.1)
set_props('progress-bar', {'value': i + 1})
set_props('status', {'children': f'Processing step {i + 1}/100...'})
# Read current value from another component
current_value = await ws.get_prop('input-field', 'value')
return f"Completed! Input was: {current_value}"IMPORTANT - Checking is_shutdown in Loops:
Long-running callbacks that use loops must check ws.is_shutdown to detect when the WebSocket connection has closed. Without this check:
- Callbacks continue running after the client disconnects, wasting server resources
set_propscalls go to a closed connection and are lost- The callback result is never delivered to the client
Only "persistent callbacks" (callbacks with no Output and no Input that use only set_props) are automatically restarted when the WebSocket reconnects. Regular callbacks with outputs are not restarted.
API:
set_props(component_id, props_dict)- Stream prop updates immediately to clientctx.websocket- Get WebSocket interface (returnsNoneif not in WS context)ws.is_shutdown- Check if the WebSocket connection has been closedawait ws.get_prop(component_id, prop_name, timeout=30.0, *, path=None)- Read a full or nested prop valueawait ws.set_prop(component_id, prop_name, value)- Set single prop (async version)await ws.close(code, reason)- Close the WebSocket connection
Use the keyword-only path argument with the same string keys and integer list
indices supported by Patch:
event = await ws.get_prop(
'store', 'data', path=['event', 'target', 'value']
)
last = await ws.get_prop('store', 'data', path=['records', -1, 'value'])- Omit
path, or useNoneor[], to read the complete property. - Negative indices count from the end of a list.
- Missing locations return
None; invalid paths raise before sending a request. - Falsy values and empty containers are preserved.
- The renderer resolves the path before WebSocket serialization, so only the selected value is returned and component props are never modified.
Use hooks to validate connections and messages:
from dash import Dash, hooks
@hooks.websocket_connect()
async def validate_connection(websocket):
"""Validate WebSocket connection before accepting."""
session_id = websocket.cookies.get("session_id")
if not session_id:
return (4001, "No session cookie")
if not await is_valid_session(session_id):
return (4002, "Invalid session")
return True # Allow connection
@hooks.websocket_message()
async def validate_message(websocket, message):
"""Validate each WebSocket message."""
session_id = websocket.cookies.get("session_id")
if not await is_session_active(session_id):
return (4002, "Session expired")
return True # Allow messageHook Return Values:
True(or truthy) - Allow connection/messageFalse- Reject with default code (4001)(code, reason)- Reject with custom close code and reason
dash/dash.py- WebSocket config in_generate_config()dash/dash-renderer/src/utils/workerClient.ts- Browser-side SharedWorker client@plotly/dash-websocket-worker/src/WebSocketManager.ts- WebSocket connection management@plotly/dash-websocket-worker/src/worker.ts- SharedWorker entry pointdash/backends/_fastapi.py- Server-side WebSocket handler
A callback defined as a generator function (or async generator function)
streams: its yields are pushed to the browser as they are produced — for LLM
token streaming, progress feeds, and long computations. There is no opt-in
keyword; dash._callback.register_callback infers it from the decorated
function (inspect.isgeneratorfunction / isasyncgenfunction) and registers
the streaming wrapper instead of the regular one.
import asyncio
from dash import callback, Output, Input, Patch
@callback(
Output('log', 'children'),
Input('btn', 'n_clicks'),
prevent_initial_call=True,
)
async def run(n):
yield 'Starting...' # replaces children immediately
async for token in llm():
p = Patch()
p += token
yield p # appends to children (incremental)
yield 'Done' # last yield = final value- Each yield has the same shape as a regular return value (one value per
Output) and replaces the outputs. Yielddash.Patchobjects for incremental updates. no_updateworks per-output within a yield; a yield where nothing updates produces no frame. RaisingPreventUpdatemid-stream ends the stream cleanly.set_props()between yields is folded into the next frame'ssideUpdate(HTTP) or streams immediately (WebSocket transport).- Intermediate frames are applied through the same renderer path as
set_props, so dependent callbacks fire per frame and loading states stay on until the stream completes (Updating...title for the whole stream). on_errorapplies per-stream: its return value becomes a final frame. Without it, an exception mid-stream sends an error frame shown in devtools; frames already applied stay applied.- The callback must be an
async defgenerator on every backend; a synchronous generator is rejected at registration, since it would occupy a server worker (or WS executor thread) for the whole stream. - HTTP streams emit a blank keepalive line every
stream_keepalive_intervalms (Dash(stream_keepalive_interval=15000)) that the callback spends between yields, so proxy idle timeouts (nginxproxy_read_timeout, 60s by default) don't close a stream mid-thought;Nonedisables it. The renderer skips blank lines. - Incompatible with
background=True,mcp_enabledandapi_endpoint(validated at registration, when the function is inspected). Clientside callbacks cannot stream at all. callback_map[callback_id]['stream']records the inferred flag server-side; it is not part of the callback spec sent to the client, which detects a stream from the response instead (NDJSON content type /streamframes).
Transport follows the callback's normal transport selection: if the callback
runs over the WebSocket callback transport (websocket=True or
websocket_callbacks=True), frames ride the open connection as
callback_response messages with stream: true; the terminal message is
{status: 'ok', stream: true, done: true}. Otherwise the HTTP POST response
streams NDJSON (application/x-ndjson), one frame per line:
{"multi": true, "response": {"<id>": {"<prop>": <value>}}, "sideUpdate": {...}?}
{"done": true} <- terminal frame
{"done": true, "error": {"message": "..."}} <- error terminal frame
The renderer applies each frame on arrival (via the sideUpdate path, so
Patch applies exactly once) and resolves the callback's execution promise
with an empty result on the terminal frame.
When the app has a shared-storage backend (the default LocalSharedStorage),
HTTP streams do not each hold their own response. The callback's POST carries
streamConnection: {requestId} and returns a fast ack; a pump
(dash/_stream_hub.py) drives the generator as an asyncio task and publishes
each frame, tagged with the request id, to the connection's shared-storage
topic. The browser's downlink (streamDownlink: {from}) reads that topic
and the client routes {rid, frame, seq} envelopes back by request id.
Callback and downlink can be on different workers -- the store is the broker
-- and a downlink always resumes from its last seq, replaying from the
store's buffer. If the buffer no longer covers the cursor (restart, owner
re-election) the server sends {reset: true} and the client fails its
in-flight streams instead of silently skipping frames.
The connection id is never chosen by the client: every stream request rides on
?endId=, the server-signed per-page-load token, and the backend derives the
id from it (get_stream_connection_id), answering 403 when it is missing or
forged -- otherwise a client could read or inject into another page's topic.
Across worker processes every worker must resolve the same signing secret
(secret_key).
Each run of streams (from the first stream after idle until none is in flight)
also carries &downlinkId=, picked fresh by the client, and the connection id
is <end_id>:<downlinkId>: every run gets its own topic and the client's
cursor restarts at 0. Without it a page that sat idle past STREAM_TOPIC_TTL would
resume a cursor into a topic the store released, get {reset: true}, and fail
its new stream. The id only partitions the page's own space, so it is not
signed (just checked against [A-Za-z0-9_-]{1,64}). The downlink lifecycle
record (connection_key) stays keyed on the end_id alone.
The downlink is hosted in a SharedWorker (dash-stream-worker.js, served like
the WebSocket worker; config.stream.worker_url) so one connection per
browser serves every tab: browsers cap HTTP/1.1 connections per host at
about six, and a downlink per tab stalls the sixth tab. The worker pins the
endId of the tab that opened the downlink while streams are in flight (all
tabs' frames flow through that one topic). The page talks to the worker
through SharedStreamClient (utils/streamClient.ts); the worker runs the
real StreamClient behind attachStreamWorkerHost
(utils/streamWorkerHost.ts). Without SharedWorker support the page falls
back to a downlink of its own.
Two downlink modes (config.stream.mode, from backend.downlink_mode):
stream(ASGI: Quart, FastAPI): one long-lived NDJSON response per browser. It costs no thread -- the subscription parks the task on a future the store resolves (StoreEngine.apoll; asyncio streams to the owner from other workers) -- so a single uvicorn worker holds thousands.poll(WSGI: Flask): a WSGI response holds a worker thread for its whole life, so an open downlink per browser exhausts a thread pool at a few dozen browsers (gunicorn--threads 2: the second browser hung everything). Instead each downlink request returns the frames queued since the cursor and ends at once (poll_downlink,Subscription.poll(0)), taking a thread for milliseconds. The worker re-polls everystream_poll_intervalms (default 100) while frames flow, backs off to five times that after two empty polls (bounding a slow stream's frame latency so frames don't bunch into one poll), and polls immediately when a new stream starts. Pumps are tasks on one event-loop thread per WSGI process (pump_to_storage), using the store's loop-nativeaget/apublish, not a thread per stream.
Lifecycle. Each downlink records its state under the connection's key in
shared storage: open/closed for a long-lived downlink, a heartbeat (at most
once a second per worker) for a polling one. Every pump checks it about every
2s and cancels its callback at its current await once the browser is gone:
a downlink closed for DOWNLINK_GRACE (10s), or no poll for POLL_GRACE
(30s -- wide, because an overloaded pool delays polls and overload must cost
latency, never the stream). A tab closing while other tabs keep the shared
downlink sends streamCancel: {requestId} per stream instead;
the same pump check picks up the per-request key. A pump that stops publishes
a terminal {"done": true} so a late-reconnecting client resolves.
Shutdown. ASGI servers drain in-flight responses before stopping and a
long-lived downlink never ends by itself, so _stream_hub installs a
SIGINT/SIGTERM handler (install_stream_shutdown_handler, at import and again
from backend startup since uvicorn replaces handlers) that runs
shutdown_active_streams -- sets the shutdown flag, cancels every pump on its
own loop, closes every open downlink subscription -- then chains to the
server's own handler. WSGI pumps also stop from an atexit hook. The pump
loop thread shrugs off exceptions raised into it (dash.testing's runner stops
every thread an app started) and is recreated if it ever dies.
Scale (this dev box, 8 cores shared with the load clients; a streaming callback per browser yielding every 0.5s; delivery = server yield to client receipt):
| server | browsers | frame delay p50 / p95 |
|---|---|---|
| uvicorn, 1 worker (FastAPI) | 1000 | 3 ms / 22 ms |
| uvicorn, 4 workers | 1000 | 1 ms / 3 ms |
gunicorn -w 4 --threads 8 (Flask, poll) |
300 | 105 ms / 200 ms |
gunicorn -w 8 --threads 8 |
1000 | 180 ms / 3.6 s (CPU-bound) |
gunicorn -w 1 (sync worker) |
50 | 50 ms / 100 ms |
Flask works and degrades gracefully -- the cost is a poll per browser per interval, so plan roughly one gunicorn worker per 150 concurrently streaming browsers -- but for thousands of concurrent streams the ASGI backends are the right tool: constant latency and a fraction of the CPU.
- Streaming is inferred from the decorated function, so another decorator
between
@callbackand the generator hides it: if that decorator returns a plain function, Dash registers a regular callback and the returned generator object fails to serialize (InvalidCallbackReturnValue: type generator). - Long streams should check
ctx.websocket.is_shutdown(WS transport) in loops; on HTTP, client disconnect raisesGeneratorExitinto the user generator at its currentyield. - Proxies and compression middleware (nginx buffering, flask-compress/gzip,
Jupyter proxies) can buffer NDJSON and defeat streaming. Dash sets
X-Accel-Buffering: no, but middleware configuration may still be needed. - Streamed frames bypass persistence (
prunePersistence/applyPersistence). - Wrapping a streamed output in
dcc.Loadinghides it for the entire stream (loading stays on by design). - Flask + async generator requires
dash[async]; frames are bridged from a private event-loop thread. flask.requestinside a streamed callback body only works on the pure-WSGI Flask path (nodash[async]/use_async); under async dispatch the request context cannot be carried into the stream. Use Dash'sctx(cookies, headers, args are captured at dispatch) instead.
dash/_callback.py-add_context_stream/async_add_context_streamwrappers, frame buildersdash/_streaming.py-StreamedCallbackResponsemarker, context-safe iteration, NDJSON helpers, keepalives, shutdown flagdash/_stream_hub.py- multiplexed transport:Downlink,poll_downlink, pumps (pump_to_storage/apump_to_storage),cancel_stream,shutdown_active_streams/install_stream_shutdown_handlerdash/_shared_storage/_engine.py,local.py-poll/apoll, loop-nativeaget/aset/apublish, async client connectiondash/backends/_flask.py,_quart.py,_fastapi.py- streaming dispatch branchesdash/backends/ws.py-make_stream_frame_emitter,consume_stream_frames/aconsume_stream_framesdash/dash-renderer/src/actions/callbacks.ts-applyStreamFrame, NDJSON reader, WS frame handlingdash/dash-renderer/src/utils/workerClient.ts- stream-awarecallback_responsehandlingdash/dash-renderer/src/utils/streamClient.ts-StreamClient(downlink + uplinks),SharedStreamClient(page side of the worker),getStreamClientdash/dash-renderer/src/utils/streamWorkerHost.ts,src/workers/streamWorker.ts- the stream SharedWorker
Dash automatically sanitizes dangerous URLs in components:
- Blocked protocols:
javascript:,vbscript: - Protected attributes:
href,src,action,formAction - Dangerous URLs replaced with
about:blank
Components with URL sanitization: html.A, html.Form, html.Iframe, html.Embed, html.Object, html.Button.
Generate hashes for inline scripts to use with CSP middleware:
from flask_talisman import Talisman
Talisman(app.server, content_security_policy={
"default-src": "'self'",
"script-src": ["'self'"] + app.csp_hashes()
})app.csp_hashes(hash_algorithm='sha256') returns base64-encoded hashes.
suppress_callback_exceptions=False(default) - Validates all callback IDs exist in layoutprevent_initial_callbacks=True- Prevents callbacks firing on page load (can also set per-callback withprevent_initial_call)
Meta tag values are HTML-escaped to prevent injection:
app = Dash(__name__, meta_tags=[
{"name": "description", "content": "Safe <content>"}
])dash/dash-renderer/src/utils/clientsideFunctions.ts- URL sanitization (clean_url)dash/dash.py:csp_hashes()- CSP hash generationtests/integration/security/- Security test coverage