Skip to content

[BUG] Callbacks double-registered when using uvicorn --reload under backend="fastapi" #3818

Description

@essol-li

Context

  • Python 3.12.13
  • uvicorn 0.49.0
  • Linux (Docker: python:3.12-slim)
  • Package version:
dash                    4.2.0

Description

When using Dash(backend="fastapi") with uvicorn's --reload flag for development, all callbacks are registered twice after the first file-change reload. This produces Duplicate callback outputs and Overlapping wildcard callback outputs errors.

Root cause: GLOBAL_CALLBACK_LIST and GLOBAL_CALLBACK_MAP in dash._callback are module-level globals that persist across uvicorn's reload cycle. When uvicorn detects a file change, it calls importlib.reload() on the application module within the same process. This re-executes all @callback decorators, appending duplicates to the never-cleared global lists.

This did not happen with the Flask backend because Werkzeug's reloader spawns a fresh subprocess (clean sys.modules, clean globals). Uvicorn's reloader operates in-process via importlib.reload().

Example:

This code below:

import os
from dash import Dash, html, dcc, callback, Output, Input

app = Dash(__name__, backend="fastapi")
app.layout = html.Div([
    html.H1("Reload Bug Demo"),
    dcc.Input(id="input", value="Hello"),
    html.Div(id="output"),
])

@callback(Output("output", "children"), Input("input", "value"))
def update(value):
    return f"You typed: {value}"
 
app.enable_dev_tools(debug=True, dev_tools_hot_reload=True)

server = app.server

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("app:server", host="0.0.0.0", port=8050, reload=True)

Run with:

pip install dash[fastapi]==4.2.0
python app.py

Will result the following error:

In the callback for output(s):
  output.children
Output 0 (output.children) is already in use.
To resolve this, set `allow_duplicate=True` on duplicate outputs, or combine the outputs into one callback function, distinguishing the trigger by using `dash.callback_context` if necessary.

Currently I have to manually clearing the private globals before registering callbacks:

from dash._callback import GLOBAL_CALLBACK_LIST, GLOBAL_CALLBACK_MAP
GLOBAL_CALLBACK_LIST.clear()
GLOBAL_CALLBACK_MAP.clear()

This works but relies on private internals (dash._callback) that could break in any future release.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions