Topic 16.5
Performance Budgets
In one line
A dashboard that runs all day fails slowly: a few listeners, timers and nodes left behind by every redraw. Agree numbers in advance (bytes, long tasks, nodes, listeners), measure them with DevTools and with counters, and ship only the code you use.
Think of it like this
An airline's luggage allowance. The rule is a number agreed before you pack, and it is checked at the desk, not after the plane lands. You decide what to leave at home. A performance budget does the same for a page: numbers agreed first, checked on every change.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- performance budget
- A written list of limits (bytes, milliseconds, counts) that a page must stay under.
- long task
- A piece of JavaScript that holds the main thread for more than 50 ms, so the page cannot respond meanwhile.
- main thread
- The one thread where the browser runs your JavaScript, lays out the page and handles clicks.
- leak
- Memory, listeners or timers that are no longer needed but are still alive.
- AbortController
- An object whose
signalcan be passed toaddEventListener; callingabort()removes those listeners together. - bundle
- The JavaScript file the build tool makes from your code and the libraries it imports.
Step by step
01Redraw 50 times, two ways
The first example wraps addEventListener, setInterval and clearInterval so it can count what is alive, then runs a redraw 50 times with a leaky version and a fixed one.
The leaky version leaves 50 resize listeners, 50 running timers and 50 extra charts. The fixed one leaves 1 of each. These are counts, so the output never changes.
02Check the budget in code
The second example reads the real size of three data files, counts nodes for 10,000 SVG circles against one canvas, and prints PASS or FAIL against a budget object. It also blocks the main thread for a moment and asks the browser whether it saw a long task.
03Look at the Performance panel
In Chromium DevTools open Performance, press record, click around the dashboard for ten seconds and stop. Find tasks with a red corner on the Main track: those are long tasks. Click one and read the call tree to see which function owns the time.
Open the Performance monitor (the three-dots menu, More tools) and leave it while the live feed runs: the DOM nodes and JS event listeners lines should stay flat.
04Measure the bundle
The terminal block shows a scratch Vite project built with d3 and with the single packages. Copy this check for your own project: the numbers depend on what you import.
Try it yourself
- 1
Tighten the budget
In the second example set
firstScreenBytesto10000. The first-screen line turns to FAIL: the budget did its job. Then decide whether to aggregate the data or raise the number, and write down why. - 2
Leak only the timer
In
drawFixedremoveclearInterval(timer);fromstop. The fixed run now printstimers: 50while the listener count stays 1: one forgotten line is one leak.
Code & diagrams
Counts, not times: they come out the same on every machine, and a test can fail on them. The listener count here covers only 'resize' on window.
Preview
Console
Nothing logged yet.Expected console output
leaky resize listeners: 50 | timers: 50 | charts: 50 | rects: 250
fixed resize listeners: 1 | timers: 1 | charts: 1 | rects: 5File sizes are bytes of the text as sent, before compression. The long-task line works in Chromium. A browser without it prints a different last line, so check support before you rely on it.
Preview
Console
Nothing logged yet.Expected console output
daily.csv 13406 bytes
status.json 1303 bytes
trips-10k.csv 487313 bytes
first screen data (daily + status): 14709 bytes <= 400000 PASS
trips file: 487313 bytes <= 500000 PASS
10,000 SVG circles add 10000 nodes: FAIL
one canvas adds 1 node: PASS
a 150 ms loop was reported as a long task: trueBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Use an anonymous function and try to remove it
In drawFixed, change window.addEventListener("resize", onResize); to window.addEventListener("resize", () => {}); and the removal to window.removeEventListener("resize", () => {});.
Myth vs fact
Myth
JavaScript has garbage collection, so I cannot leak memory.
Fact
The collector frees what nothing can reach. A window listener, a running timer or a global list still reaches your old charts, so they stay alive.
Myth
Import from d3 and the bundler throws away what I do not use, so it makes no difference.
Fact
It helps, but the measured entry through d3 was about 48 kB against about 19 kB through the two packages. Measure your own build.
Myth
Performance is about speed on my laptop.
Fact
It is about the slowest screen that matters, over hours. Test with throttled CPU and watch the counts over a long run.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
removeEventListenerwith{ capture: true }must match thecaptureflag used to add. A mismatch fails silently, like the different-function mistake. - ▸
A chart that re-creates its scales and axes on every message wastes work even without a leak. Keep scales in a closure, update only their domains, and use a keyed data join so existing nodes are reused.
- ▸
performance.measureandperformance.marklet a redraw label itself in the Performance panel's Timings track. Use them for the two or three redraws you care about, and keep timings out of tests: they vary. - ▸
Heap growth shows in a long run: take a heap snapshot, leave the feed on for ten minutes, take another, and compare. Detached DOM nodes (removed from the page but still referenced) are the usual finding.
Remember this
- 1
The problem this solves: a dashboard on a wall runs for days. A redraw that leaves one listener and one timer behind looks fine for five minutes in your editor and brings the tab down by the afternoon. "It feels fast" is not a number, and the leak does not show on a fast laptop.
- 2
A budget is a short list of numbers. For example: first-load JavaScript under 100 kB, the first screen's data under 400 kB, no task longer than 50 ms during a redraw, fewer than 1,500 DOM nodes, and the count of listeners and timers flat after 100 redraws. Write them in the repository and check them in tests (Topic 16.7), so a change that breaks one is seen the day it is made.
- 3
Measure with DevTools. In Chromium DevTools, the Performance panel records a run: the Main track shows tasks, and a task longer than 50 ms gets a red corner (a long task: input waits while it runs). The Performance monitor panel shows live counts of DOM nodes, JS event listeners and heap size, so you watch a redraw loop climb. The Memory panel takes heap snapshots to compare before and after. Test with CPU throttled (4× slowdown): wall screens are often weak computers.
- 4
Long tasks. One task that runs for 300 ms freezes clicks, hover and the live feed. Typical causes: parsing a 10 MB file in one go, and drawing 20,000 nodes in one loop. The cures are less work (aggregate first, Phase 12), work somewhere else (a Web Worker), or smaller slices with a yield between them. Code can watch for them with
PerformanceObserver({ type: "longtask" })where the browser supports it (Chromium does; check the other browsers before you rely on it). - 5
Leaks from redrawing. A redraw that adds instead of replacing leaks three things. Listeners:
window.addEventListener("resize", () => …)with a new anonymous function each time can never be removed, so they pile up. Timers: asetIntervalthat nobody clears keeps running. Nodes: appending a new<svg>without removing the old one. The first example does 50 redraws two ways and counts what is left. - 6
Count, do not time. Times change with the machine, but counts do not: 50 redraws should leave 1 listener, 1 timer and the same number of nodes. A count can be a test that fails. The fix is one
stop()per chart that clears what it started, anAbortControllersignal to remove a group of listeners at once, and joins that update elements instead of appending new ones. - 7
Ship only the D3 you use.
d3is a bundle of about thirty small packages (d3-scale,d3-shape…). A modern bundler drops what you do not import, but importing from the wholed3entry still pulled in far more code than importing from the parts. Measured in a scratch Vite project: one scale andmaxcame to about 48 kB throughd3and about 19 kB throughd3-scaleandd3-array. The terminal block shows the runs. - 8
Know what a budget cannot say. It does not make a design good. It stops a design from getting worse without anyone noticing, and it tells you where to look when the wall screen stutters.
Explain it without notes
Why is a count of listeners and nodes a better test than a time?
Why does removeEventListener with a new arrow function not remove the listener?
What is a long task and why does it matter on a live dashboard?
How do you decide what a performance budget should be?
Practice
Write countNodes(root) that returns the number of elements inside root, and use it to assert that 50 redraws leave the same count as 1. Which method do you use to count?
A chart adds document.addEventListener("keydown", …) in drawChart. Rewrite it so a stop() removes it, using an AbortController.
Trade-offs
- ↔
Strict budgets slow feature work a little and catch regressions early. Loose ones are never broken and never useful. Pick numbers that you can meet today and that would hurt if they doubled.
- ↔
Splitting D3 into single packages saves bytes but adds more import lines and version numbers to keep in step. The bigger saving is usually in the data, not in the library.
Done when you can
I can write a budget as numbers and say how each is measured.
I can find long tasks, node counts and listener counts in DevTools.
I can count listeners, timers and nodes across redraws and make the count stay flat.
I can remove a listener correctly, or remove a group with an AbortController.
I can say what importing only some D3 packages saves, and measure it in my project.