2025, Oct 02 09:00

Faster Plotly Animations in Jupyter/VSCode: Batch-Update Multiple Traces with FigureWidget to Reduce Front-End Overhead

Learn how to speed up Plotly in Jupyter and VSCode by batching updates with FigureWidget. Update multiple traces per frame, cut comms overhead, and get smooth live animations.

Updating multiple Plotly traces live inside a Jupyter notebook running in VSCode can feel painfully slow when you push changes trace-by-trace. Below is a compact walkthrough that reproduces the bottleneck and then fixes it by batching updates so the front end receives one consolidated event per time step.

Reproducing the slowdown

The following example synthesizes a few dozen time series and attempts to animate them by updating each trace individually. Run the first block to render the figure, let it appear, and then run the second block to perform the updates.

import plotly.graph_objects as go
import numpy as np
import random

perT = 20
t_steps_full = np.arange(1, 101, 2)
jitter = 0.25
decay_const = 60
series_count = 32
series_ids = np.arange(0, series_count, 1)
t_axis = np.arange(1, 51, 2)
y_store = [np.array([np.sin(2*np.pi*tv/perT)*np.exp(-tv/decay_const) + random.random()*jitter for tv in t_axis]) for _ in series_ids]
fig_widget = go.FigureWidget()

for idx, _sid in enumerate(series_ids):
    fig_widget.add_trace(go.Scatter(x=t_axis, y=y_store[idx][0:1], mode='lines+markers', name='lines'))

fig_widget.show()
for step_idx, t_point in enumerate(t_axis):
    for s in range(len(series_ids)):
        fig_widget.data[s].x = t_axis[:step_idx]
        fig_widget.data[s].y = y_store[s][0:step_idx]

With many traces, the loop above is slow, taking a couple of seconds per iteration. Dropping the number of traces makes it much faster, which hints that the per-trace update pattern is the culprit.

What actually hurts performance

Each attribute assignment on a trace translates to an update sent from Python to the front end. When you do this repeatedly for many traces, the overhead dominates. The effect compounds inside notebook environments because every change rides through the Jupyter comms channel. The end result is a visible lag that grows with the number of traces.

A faster approach: batch updates

Instead of touching each trace separately, prepare the new data for all traces and then apply it within a single batched operation. Plotly provides fig.batch_update() specifically to bundle changes and send them as one event to Plotly.js.

import plotly.graph_objects as go
import numpy as np
import random
import time

perT = 20
t_steps_full = np.arange(1, 101, 2)
jitter = 0.25
decay_const = 60
series_count = 32
series_ids = np.arange(0, series_count, 1)
t_axis = np.arange(1, 51, 2)
y_store = [np.array([np.sin(2*np.pi*tt/perT)*np.exp(-tt/decay_const) + random.random()*jitter for tt in t_axis]) for _ in series_ids]

fig_widget = go.FigureWidget()
for s_idx, _ in enumerate(series_ids):
    fig_widget.add_trace(go.Scatter(x=[], y=[], mode='lines+markers', name=f'Trace {s_idx+1}'))

fig_widget.show()

for j in range(len(t_axis)):
    updates = [{'x': t_axis[:j+1], 'y': y_store[i][:j+1]} for i in range(series_count)]
    with fig_widget.batch_update():
        for k in range(series_count):
            fig_widget.data[k].x = updates[k]['x']
            fig_widget.data[k].y = updates[k]['y']
    time.sleep(0.01)

This pattern avoids the per-trace chatter and tells Plotly.js to apply all changes at once. In practice, it makes multi-trace animations feel much snappier.

Related observations from practice

If you need everything to run in a single cell, using display(fig) instead of fig.show(), followed by a short wait of about a second, can allow the full sequence to render and update as intended. There is also another Plotly example at https://stackoverflow.com/a/78590766/8508004; it can be a bit quirky in that you may need to run it twice and then press the play button. Additionally, a setup like this can trigger IOPub message rate exceeded with certain patterns; an alternative single-cell example is referenced at https://stackoverflow.com/a/66923695/8508004. If you are not tied to Plotly, you can explore the collection of animated Matplotlib examples here: https://github.com/fomightez/animated_matplotlib-binder.

Why this matters

Notebook-based interactive visualizations live and die by how efficiently they communicate state changes to the browser. When you scale beyond one or two traces, the difference between sending dozens of micro-updates and one batched update per frame is the difference between a smooth live plot and a stuttering one.

Takeaways

Pre-create all traces with empty data, assemble per-frame arrays for every series, and push them through fig.batch_update(). If a single cell is required, consider display(fig) with a brief delay so the figure can initialize. And if performance still feels off, remember that fewer traces reduce the overhead dramatically, which aligns with the behavior seen when ntraces is lowered.

The article is based on a question from StackOverflow by d401tq and an answer by jei.