A Naive Append Kills a Live Chart in Under a Minute
Appending each tick to a growing array looks fine for the first ten seconds. Measured at 3,000 ticks per second, it blows the frame budget at around 55 and goes superlinear after that.

The live chart works. You demo it, it is smooth, everyone is happy. Then it runs through a market open and the tab is unusable.
The bug is one line, and it looks correct
setData((data) => [...data, tick]);
That array never shrinks. Every tick copies everything that came before it, so the cost of a tick grows with how long the page has been open. It is not a leak in the classic sense — nothing is unreachable — but the effect is the same, and worse, because the work grows too.
Watch frame time, not frame rate
This is the part that hides the problem. requestAnimationFrame is capped at the
display refresh rate, so frame rate reads a flat 60 until you are already over
budget. It is a pass/fail light with no dial.
I measured both while building the tool below. At 3,000 ticks per second the naive append was doing 68 million array-element copies per second and frame rate was still a perfectly healthy 60. Frame time was 2.46 ms — 15% of the 16.7 ms budget, and climbing.
0.00 ms per frame · 0% of a 16.7 ms frame
0 points held in memory
0 values copied per second
The ramp, measured
Naive append, 3,000 ticks per second, 2,000-point window, one ordinary laptop:
| Elapsed | Frame time | Frame budget | Points held |
|---|---|---|---|
| 10 s | 3.06 ms | 18% | 29,045 |
| 30 s | 5.73 ms | 34% | 90,142 |
| 50 s | 10.99 ms | 66% | 149,717 |
| 60 s | 21 ms | 126% | 180,394 |
| 70 s | 72 ms | 431% | 210,149 |
It crosses the budget at around 55 seconds. Then it goes superlinear: 21 ms to 72 ms in ten seconds. That is a feedback loop — once a frame takes longer than the tick interval, more ticks pile up per frame, so each frame copies more, so it takes longer still.
Nothing about the first thirty seconds warns you. That is why this ships.
Three strategies, same window
Same test, at ten seconds in:
| Strategy | Frame time | Values copied / second | Points held |
|---|---|---|---|
[...data, tick] |
3.06 ms | 68,523,000 | grows forever |
[...data.slice(-n), tick] |
0.29 ms | 6,000,000 | 2,000 |
| Ring buffer | 0.04 ms | 3,000 | 2,000 |
The ring buffer is 61× cheaper per frame than the naive append and flat — its cost
does not depend on window size or elapsed time, because it overwrites one slot in a
pre-allocated Float64Array and never allocates again.
Note what slicing costs, though: six million values copied per second in order to display two thousand. It is bounded, which is the important part, but it is not free.
What to do
- Bound the buffer, always. If you change one thing, make it this. Slicing to a window is three characters more than the naive append and it removes the unbounded growth entirely.
- Measure frame time, not frame rate, and compare it against 16.7 ms. Frame rate only tells you after it is too late.
- Reach for a ring buffer when the tick rate is in the thousands — market data, telemetry, audio. Below that, slicing is fine and simpler, and simpler wins.
Common follow-ups
Is frame rate not the right thing to watch?
No, and this is the trap. requestAnimationFrame is capped at the display refresh rate, so frame rate reads a flat 60 until you are already over budget. Frame time shows the ramp while you still have headroom to spend.
Why does it get worse so fast once it crosses?
Because it becomes a feedback loop. Once a frame takes longer than the tick interval, more ticks pile up per frame, so each frame copies more, so it takes longer. Measured: 21 ms at sixty seconds, 72 ms at seventy.
Is slicing to a window good enough?
Usually yes. It bounds memory and it was 8x cheaper than the naive append in the same test. But it still allocates an n-length array every tick — at 3,000 ticks per second with a 2,000-point window that is six million values copied per second to display two thousand.
When is a ring buffer worth the extra code?
When the tick rate is high enough that per-tick allocation shows up in a profile, which in practice means market data, telemetry, and audio. For a dashboard refreshing once a second, slicing is fine and simpler.