Repository navigation
[BUG][4.2.0rc1] backends._fastapi: unhandled WebSocketDisconnect cascades into 3-frame ASGI traceback on idle-WS reap #3790
Description
When a Dash 4.2 WebSocket-served callback handler tries to send a response on a socket the client has already closed (very common for long-idle browser tabs,
mobile background tabs, or proxy/edge reaps), dash.backends._fastapi doesn't catch starlette.websockets.WebSocketDisconnect around websocket.send_json(...).
The exception escapes the task, the next iteration of the receive loop then calls websocket.receive_json() on the already-closed socket, and a second handler
re-tries send_json after Starlette has emitted its close frame — producing three back-to-back tracebacks per disconnected client and an ERROR: Exception in
ASGI application from uvicorn. The client experience is unaffected (it's already gone), but each stale-WS reap costs ~50 log lines and surfaces as an ASGI
error in observability tooling.
Environment
- dash==4.2.0rc1 (backend="fastapi", websocket_callbacks=True)
- Python 3.12
- starlette (current FastAPI dep), uvicorn (websockets impl), websockets package
- Render.com web service behind their edge proxy (i.e. proxy may reap long-idle sockets)
- Optional but present in the stack: dash-clerk-auth ASGI middleware (not the source of the bug — exception originates inside dash.backends._fastapi; included
only because it appears in the traceback)
Observed behavior
After a browser tab sat idle on a Dash page for ~7 hours, the WebSocket dropped (proxy reap) and the server logged:
future: <Task finished name='Task-2146'
coro=<FastAPIDashServer.serve_websocket_callback..websocket_handler..execute_callback_task() done,
defined at /usr/local/lib/python3.12/site-packages/dash/backends/_fastapi.py:727>
exception=WebSocketDisconnect()>
Traceback (most recent call last):
File ".../uvicorn/protocols/websockets/websockets_sansio_impl.py", line 433, in send
self.conn.send_text(text_data.encode())
File ".../websockets/protocol.py", line 337, in send_text
raise InvalidState(f"connection is {self.state.name.lower()}")
websockets.exceptions.InvalidState: connection is closing
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File ".../starlette/websockets.py", line 86, in send
await self._send(message)
...
File ".../uvicorn/protocols/websockets/websockets_sansio_impl.py", line 452, in send
raise ClientDisconnected()
uvicorn.protocols.utils.ClientDisconnected
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File ".../dash/backends/_fastapi.py", line 735, in execute_callback_task
await websocket.send_json(
File ".../starlette/websockets.py", line 176, in send_json
await self.send({"type": "websocket.send", "text": text})
File ".../starlette/websockets.py", line 89, in send
raise WebSocketDisconnect(code=1006)
starlette.websockets.WebSocketDisconnect
Immediately after, a sibling task hits the same dead socket on the retry path:
2026-05-20 03:17:05,883 ERROR Task exception was never retrieved
future: <Task finished name='Task-2147' ... exception=RuntimeError('Cannot call "send" once a close message has been sent.')>
Traceback (most recent call last):
File ".../dash/backends/_fastapi.py", line 735, in execute_callback_task
await websocket.send_json(
File ".../starlette/websockets.py", line 98, in send
raise RuntimeError('Cannot call "send" once a close message has been sent.')
RuntimeError: Cannot call "send" once a close message has been sent.
And finally the receive loop in the handler itself tries to read from the now-fully-closed socket:
ERROR: Exception in ASGI application
Traceback (most recent call last):
...
File ".../dash/backends/_fastapi.py", line 748, in websocket_handler
message = await websocket.receive_json()
File ".../starlette/websockets.py", line 134, in receive_json
raise RuntimeError('WebSocket is not connected. Need to call "accept" first.')
RuntimeError: WebSocket is not connected. Need to call "accept" first.
The full ASGI middleware chain (FastAPI → custom middleware → auth._asgi → SessionMiddleware → dash.backends._fastapi.call → starlette router →
dash.backends._fastapi.websocket_handler) propagates the final RuntimeError all the way to uvicorn, which logs it as ERROR: Exception in ASGI application.
Triggering scenario (deterministic)
- Client opens a Dash 4.2 page with backend="fastapi" and websocket_callbacks=True.
- The WebSocket completes the initial dependency exchange and goes idle.
- The client disappears in a way that the server learns about between the dispatch of a callback and its response — most reliably reproduced by a proxy/edge
reap on a long-idle socket, but also by simply closing the tab while a callback is in flight, or by mobile background-tab kill. - The server tries to deliver the callback response → cascade begins.
Root cause analysis
In dash/backends/_fastapi.py (rc1 line numbers — please confirm against current HEAD):
- Line 735 (execute_callback_task): await websocket.send_json(...) is not wrapped in a handler for starlette.websockets.WebSocketDisconnect or
uvicorn.protocols.utils.ClientDisconnected. When the underlying socket is in closing/closed, starlette.websockets.WebSocket.send raises
WebSocketDisconnect(code=1006); the task exits with exception=WebSocketDisconnect() and is never retrieved, so Python emits the "Task exception was never
retrieved" warning and the framework keeps trying. - Line 735 (a second task): a second concurrent execute_callback_task invocation hits send_json after starlette has already emitted its close frame for the
same socket, so it raises RuntimeError('Cannot call "send" once a close message has been sent.'). - Line 748 (websocket_handler): the outer receive loop calls await websocket.receive_json() without first checking websocket.client_state /
websocket.application_state; on the now-closed socket starlette raises RuntimeError('WebSocket is not connected. Need to call "accept" first.'), which escapes
to uvicorn and surfaces as ERROR: Exception in ASGI application.
Net: a single client disconnect produces (1) an unhandled task exception, (2) a "Cannot send after close" follow-up, (3) an unhandled receive on a closed
socket — each with a full multi-frame traceback.
Suggested fix
Conceptually, every send_json/receive_json on the WS in _fastapi.py should treat WebSocketDisconnect and RuntimeError-on-closed-socket as a clean terminal
condition:
dash/backends/_fastapi.py — around the existing send/receive sites
from starlette.websockets import WebSocketDisconnect, WebSocketState
_DISCONNECT_EXC = (WebSocketDisconnect,)
line ~735 in execute_callback_task
try:
if websocket.application_state == WebSocketState.CONNECTED:
await websocket.send_json(payload)
else:
logger.debug("WS callback response dropped: socket already closed")
except _DISCONNECT_EXC:
logger.debug("WS callback response: client disconnected mid-send")
except RuntimeError as exc:
# 'Cannot call "send" once a close message has been sent.' /
# 'WebSocket is not connected. ...' — both reduce to "client gone".
if "close" in str(exc).lower() or "not connected" in str(exc).lower():
logger.debug("WS callback response: %s", exc)
else:
raise
line ~748 in websocket_handler
while True:
if websocket.application_state != WebSocketState.CONNECTED:
break
try:
message = await websocket.receive_json()
except _DISCONNECT_EXC:
logger.debug("WS callback receive: client disconnected")
break
except RuntimeError as exc:
if "not connected" in str(exc).lower():
break
raise
...
The key invariants:
- Never call send_json without checking application_state == CONNECTED first, or alternatively, always wrap it in try/except WebSocketDisconnect. Currently
neither is true at line 735. - Catch WebSocketDisconnect in the receive loop, and exit the loop cleanly. The current receive_json at line 748 is bare.
- Treat RuntimeError messages mentioning "close" or "not connected" as the same terminal condition — starlette uses RuntimeError (not WebSocketDisconnect) for
the "send after close-frame-sent" and "receive before accept" cases, which is a starlette API quirk worth absorbing here rather than asking every framework
user to.
Impact
- No user-visible regression (the client is already gone in every case observed).
- Significant log/observability noise: each stale-WS reap produces ~50 lines of traceback across three tasks, surfaces as ERROR: Exception in ASGI application
from uvicorn, and triggers Python's "Task exception was never retrieved" warning. At scale this will dominate error dashboards for any deployment behind a
reaping proxy (Render, Heroku, Cloud Run, Cloudflare, etc.) or with mobile clients. - Mitigation in user code is awkward: a wrapping ASGI middleware can swallow the exceptions, but only by also masking real RuntimeErrors in handlers — I thought the
right fix belongs inside dash.backends._fastapi.
Activity
AnnMarieW commentedon May 20, 2026
Hi @pip-install-python
Do you see the same issue on the latest version 4.2.0rc3?
Note when upgrading -
ctx.get_websocketwas renamed toctx.websocketpip-install-python commentedon May 20, 2026
Yeah, was able to get upgraded to the 4.2.0rc3 last night and the issue was still percistant from what i can tell. I am testing 4.2.0 and the new backends=fastapi in a fairly demanding way. Most seems stable, just noticed this recurring bug in the logs between the rc1, rc2 and rc3 builds
T4rk1n commentedon May 21, 2026
I think this is same behavior I am describing here: #3788
Might have a little different behavior on the earlier rc1 since there was no threadpool, but still the same issue, a running websocket callback on the backend is in a bad state if the frontend disconnect/reconnect with a race condition on the is_shutdown.