Summary
In an app that combines:
- a self-retriggering callback chain (a callback that writes its own
dcc.Store Input via set_props),
- a
dcc.Interval polling in parallel, and
- a container appended to via
Patch() that is never cleared,
the per-round-trip callback overhead grows steadily with each subsequent run of the chain.
The growing container in our case is a log view: a background dcc.Interval fetches newly produced log records once per second and appends them to a scrollable html.Div with Patch().append(...). The log is intentionally never truncated, so its child count only grows during a page session.
The effect is that a user-visible operation performing a fixed number of callback round-trips takes longer every time it is repeated in the same page session — about 5× - 100× slower after 11 repetitions in the minimal example below, and considerably worse in our real application, where it became unusable after a handful of operations.
This does not happen on Dash 4.1.0, where per-step overhead stays flat. We had to pin to 4.1.0 because of it.
Disabling the dcc.Interval (so nothing is appended to the log view) removes the slowdown entirely — that is how we originally isolated and found the cause, but the problem itself seems to be in the Patch itself.
Expected behavior
Per-round-trip overhead should stay almost roughly constant across repeated runs. Appending to a growing container should cost time proportional to the appended nodes, not to the total number of nodes already present, and it should not make unrelated callbacks progressively slower.
Actual behavior
Per-round-trip overhead drastically grows, monotonically with the number of nodes accumulated in the Patch-updated container.
Minimal reproducible example
import time
from dash import Dash, Input, Output, Patch, callback, ctx, dcc, html, no_update, set_props
STEPS = 50 # callback round-trips per run
LOGS_PER_STEP = 5 # log records appended to the growing container per step
LOG: list[str] = [] # log records produced by the chain, drained by the interval
CURSOR = 0 # how much of LOG the client already has
REPORT: list[str] = [] # measurement history
app = Dash(__name__)
app.layout = html.Div([
html.Button("Run", id="run", n_clicks=0),
html.Pre(id="report"),
dcc.Store(id="store", data={"step": None, "t0": 0, "run": 0}),
dcc.Interval(id="tick", interval=1000),
html.Div(id="log", style={"height": "200px", "overflowY": "auto"}),
])
@callback(
Output("report", "children"),
Output("run", "disabled"),
Input("run", "n_clicks"),
Input("store", "data"),
prevent_initial_call=True,
)
def chain(_clicks, data):
"""Self-retriggering chain: writes its own Input via set_props."""
if ctx.triggered_id == "run":
set_props("store", {"data": {"step": 0, "t0": time.monotonic(), "run": data["run"] + 1}})
return no_update, True
step = data["step"]
if step is None:
return no_update, no_update
if step < STEPS:
LOG.extend(f"run {data['run']} step {step} rec {i}" for i in range(LOGS_PER_STEP))
set_props("store", {"data": {**data, "step": step + 1}})
return no_update, True
elapsed = time.monotonic() - data["t0"]
set_props("store", {"data": {**data, "step": None}})
line = f"run {data['run']:>3}: {elapsed:6.3f}s total, {elapsed / STEPS * 1000:6.1f} ms/step, {len(LOG)} nodes"
print(line, flush=True)
REPORT.append(line)
return "\n".join(REPORT), False
@callback(Output("log", "children"), Input("tick", "n_intervals"), prevent_initial_call=True)
def poll(_n):
"""Log view: appends new records to a container that is never cleared."""
global CURSOR
if CURSOR >= len(LOG):
return no_update
patch = Patch()
for record in LOG[CURSOR:]:
patch.append(html.Div(record))
CURSOR = len(LOG)
return patch
if __name__ == "__main__":
app.run(debug=False)
Steps to reproduce
- Run the app and open a single tab (make sure no stale tabs from earlier runs are open).
- Click Run, wait until the button is re-enabled and a new line appears.
- Repeat ~15 times without reloading the page.
- Observe the
ms/step column growing while STEPS stays constant at 50.
Each run performs exactly STEPS callback round-trips and does no real work, so the reported ms/step is pure Dash overhead.
Measurements
Dash 4.2.0 — the chain does no work; only the log container grows:

Roughly a 5× - 40× increase in per-round-trip overhead over 14 identical runs, correlating with the node count of the log container.
Dash 4.41 — same procedure - the result is even worse:

Dash 4.1.0 — same procedure:

There's still a grow but it seems acceptable.
Environment
Last proper working version of Dash is 4.1.0.
dash_ag_grid 35.3.0
dash-bootstrap-components 2.0.4
dash_cytoscape 1.0.2
dash-extensions 2.0.6
- Python: 3.10.11/3.13.14
- OS: Windows 11
- Browser: Chrome
- Version: Version 151.0.7922.72 (Official Build) (64-bit)
| Server | Flask development server, debug=False |
Additional notes
- Disabling the
dcc.Interval (so nothing is appended to the log container) removes the slowdown completely.
- The slowdown accumulates within a page session.
- In our real application the same pattern appears with three background
dcc.Intervals and a much larger layout, where the degradation is significantly more severe.
Screenshots
Dash 4.1.0

Dash 4.2.0

Dash 4.4.1

Summary
In an app that combines:
dcc.StoreInputviaset_props),dcc.Intervalpolling in parallel, andPatch()that is never cleared,the per-round-trip callback overhead grows steadily with each subsequent run of the chain.
The growing container in our case is a log view: a background
dcc.Intervalfetches newly produced log records once per second and appends them to a scrollablehtml.DivwithPatch().append(...). The log is intentionally never truncated, so its child count only grows during a page session.The effect is that a user-visible operation performing a fixed number of callback round-trips takes longer every time it is repeated in the same page session — about 5× - 100× slower after 11 repetitions in the minimal example below, and considerably worse in our real application, where it became unusable after a handful of operations.
This does not happen on Dash 4.1.0, where per-step overhead stays flat. We had to pin to 4.1.0 because of it.
Disabling the
dcc.Interval(so nothing is appended to the log view) removes the slowdown entirely — that is how we originally isolated and found the cause, but the problem itself seems to be in thePatchitself.Expected behavior
Per-round-trip overhead should stay almost roughly constant across repeated runs. Appending to a growing container should cost time proportional to the appended nodes, not to the total number of nodes already present, and it should not make unrelated callbacks progressively slower.
Actual behavior
Per-round-trip overhead drastically grows, monotonically with the number of nodes accumulated in the
Patch-updated container.Minimal reproducible example
Steps to reproduce
ms/stepcolumn growing whileSTEPSstays constant at 50.Each run performs exactly
STEPScallback round-trips and does no real work, so the reportedms/stepis pure Dash overhead.Measurements
Dash 4.2.0 — the chain does no work; only the log container grows:

Roughly a 5× - 40× increase in per-round-trip overhead over 14 identical runs, correlating with the node count of the log container.
Dash 4.41 — same procedure - the result is even worse:

Dash 4.1.0 — same procedure:

There's still a grow but it seems acceptable.
Environment
Last proper working version of Dash is 4.1.0.
| Server | Flask development server,
debug=False|Additional notes
dcc.Interval(so nothing is appended to the log container) removes the slowdown completely.dcc.Intervals and a much larger layout, where the degradation is significantly more severe.Screenshots

Dash 4.1.0
Dash 4.2.0

Dash 4.4.1
