Describe your context
- Dash version: 4.3.0
- Python version: 3.13.7
- Backend: FastAPI (
server="fastapi"). Confirmed the same code path is shared by the Quart backend (dash/backends/_quart.py imports the same run_callback_in_executor from dash/backends/ws.py), so this likely reproduces there too.
Describe the bug
websocket=True callbacks cannot see contextvars.ContextVar values set by ASGI middleware upstream of Dash (e.g. a session/auth ContextVar bound once per WebSocket connection by a custom Starlette/ASGI middleware). Regular HTTP callbacks running through the same middleware stack see the ContextVar correctly — only websocket=True callbacks lose it.
Root cause
https://github.com/plotly/dash/blob/v4.3.0/dash/backends/ws.py#L394-L451
def run_callback_in_executor(
executor: ThreadPoolExecutor,
dash_app: "dash.Dash",
payload: CallbackExecutionBody,
ws_callback: DashWebsocketCallback,
response_adapter: "ResponseAdapter",
) -> concurrent.futures.Future:
def execute() -> dict:
try:
...
ctx = copy_context() # <-- called here
...
response_data = ctx.run(run_callback)
...
return executor.submit(execute) # <-- but execute() already runs in a new thread
execute is submitted to a ThreadPoolExecutor via executor.submit(execute). contextvars.copy_context() is called inside execute(), i.e. after it is already running on the fresh worker thread the executor spawned. Per the contextvars docs, a new threading.Thread (and, transitively, each new worker thread owned by a ThreadPoolExecutor) starts with its own top-level Context containing only default values — it does not inherit ContextVar values set on the thread that called executor.submit(). So copy_context() here just snapshots an already-empty context; it never captures whatever the ASGI request-handling coroutine (running on the event-loop thread) had set moments earlier via some_var.set(...).
This is different from asyncio.get_running_loop().run_in_executor(...) / asyncio.to_thread(...), both of which call contextvars.copy_context() before dispatching to the executor and explicitly run the target inside that captured context — which is exactly why this pattern normally "just works" with asyncio, and why it's surprising here.
Practical impact: any app that uses ASGI middleware to bind per-request/per-connection state into a ContextVar (a common pattern for e.g. request-scoped DB sessions, auth/session context, tracing context) and also uses websocket=True callbacks will silently get None/default values for that state inside the callback body — with no exception, just wrong data. In our case this manifested as an authenticated user's session/role data reading back empty only inside websocket=True callbacks, while working normally in every other callback and route on the same connection.
Suggested fix
Capture copy_context() in run_callback_in_executor itself — synchronously, before executor.submit() — so it snapshots the context on the calling thread (the event-loop thread, where the ASGI middleware chain, including SessionMiddleware-style ContextVar binding, has already run for this request/connection):
def run_callback_in_executor(
executor: ThreadPoolExecutor,
dash_app: "dash.Dash",
payload: CallbackExecutionBody,
ws_callback: DashWebsocketCallback,
response_adapter: "ResponseAdapter",
) -> concurrent.futures.Future:
caller_ctx = copy_context() # captured on the calling (event-loop) thread
def execute() -> dict:
try:
cb_ctx = create_ws_context(payload, response_adapter, ws_callback)
func = dash_app._prepare_callback(cb_ctx, payload)
args = dash_app._inputs_to_vals(cb_ctx.inputs_list + cb_ctx.states_list)
partial_func = dash_app._execute_callback(func, args, cb_ctx.outputs_list, cb_ctx)
def run_callback():
result = partial_func()
if inspect.iscoroutine(result):
return asyncio.run(result)
return result
response_data = caller_ctx.run(run_callback)
return {"status": "ok", "data": json.loads(response_data)}
except PreventUpdate:
return {"status": "prevent_update"}
except WebsocketDisconnected:
return {"status": "prevent_update"}
except Exception as e:
traceback.print_exc()
return {"status": "error", "message": str(e)}
return executor.submit(execute)
The same fix applies to dash/backends/_quart.py, which imports and calls the same function.
Reproduction sketch
- Add a pure-ASGI middleware (à la Starlette's
SessionMiddleware) that does some_var.set(value) once per connection (both "http" and "websocket" scope types) before calling into the downstream app, and resets it in a finally.
- Add an ordinary HTTP callback/route that reads
some_var.get() — it correctly sees value.
- Add a
@callback(persistent=True, websocket=True) callback that reads some_var.get() inside its body — it sees the ContextVar's default, not value.
Current workaround
We're monkeypatching run_callback_in_executor at app startup (reassigning the function object on dash.backends.ws, dash.backends._fastapi, and dash.backends._quart) with the copy_context()-before-submit() version above. Works, but obviously fragile across Dash upgrades — would much rather have this fixed upstream.
Happy to open a PR with the one-line-timing fix above if that's useful.
Describe your context
server="fastapi"). Confirmed the same code path is shared by the Quart backend (dash/backends/_quart.pyimports the samerun_callback_in_executorfromdash/backends/ws.py), so this likely reproduces there too.Describe the bug
websocket=Truecallbacks cannot seecontextvars.ContextVarvalues set by ASGI middleware upstream of Dash (e.g. a session/auth ContextVar bound once per WebSocket connection by a customStarlette/ASGI middleware). Regular HTTP callbacks running through the same middleware stack see the ContextVar correctly — onlywebsocket=Truecallbacks lose it.Root cause
https://github.com/plotly/dash/blob/v4.3.0/dash/backends/ws.py#L394-L451
executeis submitted to aThreadPoolExecutorviaexecutor.submit(execute).contextvars.copy_context()is called insideexecute(), i.e. after it is already running on the fresh worker thread the executor spawned. Per thecontextvarsdocs, a newthreading.Thread(and, transitively, each new worker thread owned by aThreadPoolExecutor) starts with its own top-levelContextcontaining only default values — it does not inherit ContextVar values set on the thread that calledexecutor.submit(). Socopy_context()here just snapshots an already-empty context; it never captures whatever the ASGI request-handling coroutine (running on the event-loop thread) had set moments earlier viasome_var.set(...).This is different from
asyncio.get_running_loop().run_in_executor(...)/asyncio.to_thread(...), both of which callcontextvars.copy_context()before dispatching to the executor and explicitly run the target inside that captured context — which is exactly why this pattern normally "just works" with asyncio, and why it's surprising here.Practical impact: any app that uses ASGI middleware to bind per-request/per-connection state into a
ContextVar(a common pattern for e.g. request-scoped DB sessions, auth/session context, tracing context) and also useswebsocket=Truecallbacks will silently getNone/default values for that state inside the callback body — with no exception, just wrong data. In our case this manifested as an authenticated user's session/role data reading back empty only insidewebsocket=Truecallbacks, while working normally in every other callback and route on the same connection.Suggested fix
Capture
copy_context()inrun_callback_in_executoritself — synchronously, beforeexecutor.submit()— so it snapshots the context on the calling thread (the event-loop thread, where the ASGI middleware chain, includingSessionMiddleware-style ContextVar binding, has already run for this request/connection):The same fix applies to
dash/backends/_quart.py, which imports and calls the same function.Reproduction sketch
SessionMiddleware) that doessome_var.set(value)once per connection (both"http"and"websocket"scope types) before calling into the downstream app, and resets it in afinally.some_var.get()— it correctly seesvalue.@callback(persistent=True, websocket=True)callback that readssome_var.get()inside its body — it sees the ContextVar's default, notvalue.Current workaround
We're monkeypatching
run_callback_in_executorat app startup (reassigning the function object ondash.backends.ws,dash.backends._fastapi, anddash.backends._quart) with thecopy_context()-before-submit()version above. Works, but obviously fragile across Dash upgrades — would much rather have this fixed upstream.Happy to open a PR with the one-line-timing fix above if that's useful.