Skip to content

[BUG] Since 4.2.0, callback-returned children dispatch one store update per descendant (O(N²)); not fixed by #3881 #4121

Open

Description

@romanov-o

Summary

Since 4.2.0 (#3570), updating a callback-returned subtree dispatches resetComponentState once per component in it. Each dispatch re-runs selectDashProps for every subscribed DashWrapper, so the cost grows with the square of the component count.

#3881 (4.5.0) fixed the remount reported in #3846 but kept this dispatch, which is the main cost. On the test below, 4.5.0 is up to about twice as slow as 4.4.1.

Reproducer

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

app = Dash(__name__)
N = 400
app.layout = html.Div([
    html.Button("Re-render", id="btn", n_clicks=0),
    html.Div(id="content"),
])

@callback(Output("content", "children"), Input("btn", "n_clicks"))
def render(n):
    return [
        html.Div([html.Span(f"Field {i}"), html.Span(f"value {i} @ click {n}")],
                 className="row")
        for i in range(N)
    ]

if __name__ == "__main__":
    app.run(debug=False)

To see the bug without a profiler, count store notifications per click in the browser console:

let n = 0; window.store.subscribe(() => n++);  // click the button, then read n

On 4.1.0 this gives 19. On 4.2.0 and later it gives 1,219: the same 19 plus one per component in the returned subtree (400 rows × 3 components).

Results

Main-thread CPU per re-render, using the same component structure with one button per size. Values are medians from 3 alternating rounds, measured with TaskDuration from CDP Performance.getMetrics. "Elements reused" is the share of the subtree's DOM nodes that are the same node objects before and after the click.

Setup: Python 3.14.5, macOS arm64, Chrome 156, React 18.3.1, debug=False.

Version 200 rows 400 rows 800 rows Store updates at 400 rows Elements reused at 400 rows
4.1.0 37 ms 56 ms 84 ms 19 100%
4.2.0 180 ms 618 ms 2,365 ms 1,219 0%
4.4.1 194 ms 647 ms 2,496 ms 1,219 0%
4.5.0 305 ms 1,124 ms 4,855 ms 1,219 100%
  • 4.3.0 and 4.4.0 match 4.4.1, and 4.5.0rc0–rc2 are similar to 4.5.0. The same code is on dev as of 2026-11-04.
  • debug=True shows the same pattern, with every version about 1.3× slower.
  • On a production dashboard built with dash-mantine-components and dash-ag-grid, a tab that rebuilds ~2,000 already rendered elements takes about 0.3 s to settle on 4.1.0, 5.6 s on 4.4.1 and 9.9 s on 4.5.0. Flows that render partly new content improve on 4.5.0 (one modal: 3.2 s to 1.5 s) but stay 2–4× slower than 4.1.0.

Expected behavior

Re-rendering the subtree should cost about what it did on 4.1.0: a handful of store updates per callback output, not one per component, and CPU time that grows linearly with the component count.

Cause

DashWrapper.tsx on v4.5.0, lines 507–514. #3570 added the dispatch and #3881 added the guard:

if (hasFreshRendered.current) {
    dispatch(resetComponentState({ itempath: componentPath }));
}
hasFreshRendered.current = true;

The dispatch is the cost. I removed only this dispatch from dash_renderer.dev.js, swapped into a debug=False server, and store updates at 400 rows fell to 19. CPU at 400 / 800 rows became:

Version Dispatch removed
4.2.0 51 / 103 ms
4.4.1 52 / 96 ms
4.5.0 52 / 87 ms

4.2.0 and 4.4.1 still remount in that build, so the dispatch, not the remount, accounts for almost all of the slowdown. I did not test the removal against #3330 (disappearing and swapping content in Dash 3), the bug the dispatch exists to fix, so I'm not proposing it as the fix.

The guard only helps first-time content. It skips the dispatch on a wrapper's first fresh render, so an update to an already rendered subtree, as in this reproducer, gets no benefit.

Why 4.5.0 is slower than 4.4.1 with the same update count

On 4.4.1, new wrappers subscribe in the same effect pass that dispatches, so many dispatches reach only part of the subtree. On 4.5.0, reused wrappers are already subscribed, so every dispatch reaches all of them. At 800 rows, selector runs rise from 3.89M to 5.80M. CPU at 800 rows on production bundles, from a separate run:

Bundle CPU
4.4.1 stock 2,368 ms
4.4.1, dispatch moved to a microtask so it runs after all subscribe effects 4,040 ms
4.5.0, remount forced and guard removed, as in 4.4.1 2,604 ms
4.5.0 stock 4,660 ms

This accounts for 70–90% of the gap between the two versions. I have not isolated the rest.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions