Skip to content

[BUG] Callback chain slows down progressively when a Patch-appended container grows (regression in 4.2.0 vs 4.1.0) #3946

Description

@mlipatro

Summary

In an app that combines:

  1. a self-retriggering callback chain (a callback that writes its own dcc.Store Input via set_props),
  2. a dcc.Interval polling in parallel, and
  3. 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

  1. Run the app and open a single tab (make sure no stale tabs from earlier runs are open).
  2. Click Run, wait until the button is re-enabled and a new line appears.
  3. Repeat ~15 times without reloading the page.
  4. 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:
Image
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:
Image

Dash 4.1.0 — same procedure:
Image
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
Image

Dash 4.2.0
Image

Dash 4.4.1
Image

Metadata

Metadata

Assignees

No one assigned

    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