# Speed up slow figure updates in a Dash app
Use this when a `dcc.Graph` update feels sluggish: the browser freezes, the
page goes unresponsive for seconds, every interaction (dropdown, slider, hover)
triggers a slow redraw, or zoom/pan resets every time the figure refreshes.
Classify WHERE the time goes before optimizing — the fix for network
round-trips, the fix for browser rendering, and the fix for lost view state are
three different things.
## 1. Classify the bottleneck first
- **Whole figure crosses the wire every update.** The callback returns a full
`figure` dict, and the update takes roughly as long over loopback as over
the network. Dev-tools Network tab shows a large `_dash-update-component`
response on every interaction. Fix with partial updates (step 2).
- **Streaming/live data redraws everything each tick.** An
`dcc.Interval`-driven callback rebuilds and returns the entire figure to
append one point. Fix with `extendData` (step 3).
- **Browser rendering is the bottleneck.** Updates arrive fast (check the Dash
dev-tools callback timing) but the tab stutters or freezes, especially with
tens of thousands of points. The default `scatter` trace renders in SVG —
fix with WebGL (step 4).
- **Zoom, pan, or legend toggles reset on every update.** The figure redraws
fine, but the user's view state is lost. Fix with `uirevision` (step 5).
- **A server round trip happens for something the browser already knows.**
E.g. toggling trace visibility, recoloring, or restyling on hover — the data
is already in the browser, only presentation changes. Fix with a clientside
callback (step 6).
## 2. Stop re-sending the whole figure: `Patch` (Dash 2.9+)
When only part of the figure changes (a title, an axis type, a trace's color),
send only the change. `Patch` operations execute in the browser:
```python
from dash import Patch, Input, Output, callback
@callback(
Output("graph", "figure"),
Input("scale", "value"),
)
def update_scale(scale):
patched = Patch()
patched["layout"]["yaxis"]["type"] = scale # assign a value
patched["layout"]["title"]["text"] = "New title" # merge into dict
patched["data"][0]["marker"]["color"] = "red" # touch one trace
patched["layout"]["annotations"].append(new_anno) # append to a list
return patched
```
Import it with `from dash import Patch`. This is the default first move: most
"slow figure update" callbacks rebuild megabytes of unchanged data because
returning the whole figure is the easy habit.
## 3. Stream points without redrawing: `extendData`
For live/interval updates, append instead of replace. The `extendData` prop
takes `[updateData, traceIndices, maxPoints]`, mirroring the
`Plotly.extendTraces` API:
```python
from dash import Input, Output, callback
import plotly.graph_objects as go
app.layout = [
dcc.Graph(
id="live",
figure=go.Figure(
data=[go.Scattergl(x=[], y=[], mode="lines")],
layout={"xaxis": {"type": "date"}, "uirevision": "live"},
),
),
dcc.Interval(id="tick", interval=1000),
]
@callback(
Output("live", "extendData"),
Input("tick", "n_intervals"),
)
def append_point(n):
x, y = read_latest_measurement()
# [data-to-append, trace indices, max points kept]
return [{"x": [[x]], "y": [[y]]}, [0], 1000]
```
Notes: `updateData` values are lists-of-lists (one list per extended trace);
`maxPoints` caps the trace length so a long-running stream doesn't eat the
browser's memory. There is also `prependData` with the same shape. If
`extendData` is an output, do not also output `figure` from the same callback.
## 4. Render large data with WebGL: `Scattergl`
The plain `scatter` trace type renders in SVG, which degrades badly past tens
of thousands of points. Switch large scatter/line traces to the WebGL-backed
equivalent — usually a one-word change:
```python
import plotly.graph_objects as go
go.Scattergl(x=xs, y=ys, mode="lines") # instead of go.Scatter(...)
```
Keep the rest of the figure identical. If you pre-aggregate for display anyway
(histograms, binned heatmaps), do the aggregation server-side and send the
small result — no rendering trick beats sending less data.
## 5. Preserve zoom/pan across updates: `uirevision`
By default, returning a new figure resets the user's zoom and pan. Set
`uirevision` on the layout to a constant string: as long as the value is
unchanged between updates, plotly.js keeps the current view:
```python
fig.update_layout(uirevision="constant")
```
Change the value only when you *want* the view to reset (e.g. a "reset view"
button sets a new revision string). Common mistake: omitting `uirevision`
while updating `figure` on a timer, which makes zooming impossible because
every tick redraws and resets.
## 6. Move presentation-only logic to the browser: clientside callbacks
If the data is already in the browser and the callback only restyles,
filters client-side, or reformats, skip the server round trip entirely:
```python
from dash import clientside_callback, Input, Output, State
clientside_callback(
"""
function(scale, fig) {
fig.layout.yaxis.type = scale;
return fig;
}
""",
Output("graph", "figure"),
Input("scale", "value"),
State("graph", "figure"),
)
```
JavaScript runs in the browser with no HTTP request. Inside the JS, return
`window.dash_clientside.no_update` to skip the update. Clientside callbacks
cannot run Python, so they are for presentation and light transforms only —
not for querying data.
## 7. Checklist for a slow figure
1. Time it: Dash dev-tools callback panel tells you server time vs total time.
2. If the `_dash-update-component` payload is large → `Patch` (or restructure
the callback to return less).
3. If it's a streaming tick → `extendData` with `maxPoints` set.
4. If the browser still stutters on render → `Scattergl`, and aggregate
server-side where possible.
5. If zoom resets on update → `uirevision` constant on the layout.
6. If the callback only restyles known data → clientside callback.