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.
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
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)
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.
Root cause analysis
In dash/backends/_fastapi.py (rc1 line numbers — please confirm against current HEAD):
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.
same socket, so it raises RuntimeError('Cannot call "send" once a close message has been sent.').
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:
neither is true at line 735.
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
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.
right fix belongs inside dash.backends._fastapi.