Artifact performance
Two numbers govern how a data page feels:
- Time to first meaningful paint — under ~1s or it feels broken
- Interaction response — under ~100ms or it feels laggy
Everything below serves one of those. Measure before optimizing; the bottleneck is rarely where it feels like it is.
Measure first
performance.mark('render-start');
renderChart(data);
performance.mark('render-end');
performance.measure('chart', 'render-start', 'render-end');
console.log(performance.getEntriesByName('chart')[0].duration.toFixed(1) + 'ms');
Then use the browser's performance panel. The usual culprits, in the order they usually appear:
- Too much embedded data parsed on load →
artifact-data-loading - Too many DOM nodes → aggregate, paginate, or virtualize
- Per-element event listeners → delegate
- Layout thrash — reading and writing layout in a loop
- Re-rendering everything when one region changed
The element-count thresholds
| Elements | Approach |
|---|---|
| < 1,000 | SVG, no special handling. Almost every finance chart lives here |
| 1,000-5,000 | SVG, but delegate events and skip per-element transitions |
| 5,000-50,000 | Canvas for the marks, SVG overlay for axes, labels, and interaction |
| > 50,000 | Aggregate or bin. This is a data problem, not a rendering one |
A bridge has 12 bars. A monthly series has 36 points. A cohort heatmap has 576 cells. Reaching for canvas on a finance chart is almost always premature — and it costs you styling, accessibility, print quality, and theming.
The hybrid pattern, when you do need it:
<canvas> the marks - drawn once, redrawn on data change
<svg> axes, gridlines, labels, hover target, selection - stays styleable
Hit-test from the data (invert the scale on cursor position) rather than from the DOM.
Delegate events
One listener on the container, not one per mark. With 2,000 bars, per-element listeners cost real memory and attach time.
// Instead of bars.forEach(b => b.addEventListener(...))
chart.addEventListener('mousemove', (e) => {
const mark = e.target.closest('[data-i]');
if (!mark) return hideTooltip();
showTooltip(data[+mark.dataset.i], e);
});
Works for dynamically added nodes too, which removes a whole class of re-binding bugs.
Batch reads and writes
Reading a layout property after a write forces a synchronous reflow. In a loop, that is quadratic.
// WRONG - read, write, read, write... forces a reflow per iteration
items.forEach(el => { el.style.top = el.offsetTop + 10 + 'px'; });
// RIGHT - read everything, then write everything
const tops = items.map(el => el.offsetTop);
items.forEach((el, i) => { el.style.top = tops[i] + 10 + 'px'; });
The layout-triggering properties to watch: offsetTop/Left/Width/Height, scrollTop,
getBoundingClientRect(), getComputedStyle().
Throttle the right things, the right way
| Event | Technique | Why |
|---|---|---|
mousemove / hover |
requestAnimationFrame |
Matches the frame rate; never more than needed |
| Text input / filter | Debounce ~250-300ms | Wait for the pause in typing |
resize |
Debounce ~150ms | Resize fires continuously while dragging |
scroll |
requestAnimationFrame or IntersectionObserver |
Never a bare listener doing layout work |
| Slider drag | rAF for the preview, debounce the expensive recompute | Instant feedback, deferred cost |
let queued = false;
container.addEventListener('mousemove', (e) => {
lastEvent = e;
if (queued) return;
queued = true;
requestAnimationFrame(() => { queued = false; updateHover(lastEvent); });
});
Build markup as a string, insert once
Repeated appendChild in a loop triggers repeated layout. Build the whole fragment, insert once.
container.innerHTML = rows.map(r => `<tr><td>${esc(r.name)}</td>…</tr>`).join('');
Escape any interpolated data. A customer name containing < breaks the markup — and in a page
that may render text from a data source, escaping is a correctness issue, not just a safety one.
For very large fragments, DocumentFragment plus a single append avoids one large innerHTML parse.
Render only what changed
The most common cause of a laggy filter is re-rendering the entire page when one region moved.
- Keep charts independent; update the ones whose data actually changed.
- Update text nodes and attributes in place rather than replacing whole subtrees.
- For a filter that only changes visibility, toggle a class instead of rebuilding.
Load-time wins
- Inline critical CSS first, and keep the
<title>in the first 8KB (artifact-architecture). - Render a skeleton immediately, then fill it. A page that paints structure in 100ms feels far faster than one that paints everything at 800ms — even though the second finishes sooner.
- Defer offscreen work. Charts below the fold can render on
IntersectionObserver. - Parse embedded data lazily if it is large — the table below the fold does not need parsing before first paint.
Animation cost
Animate only transform and opacity — they are composited and do not trigger layout. Animating
width, height, top, or left forces layout on every frame.
Cap simultaneous transitions. 500 bars easing at once will drop frames on a modest laptop; stagger
them or animate the group. And honour prefers-reduced-motion — see motion-and-transitions.
Related skills
artifact-data-loading— the upstream fix for most performance problemssvg-charting— the SVG/canvas decision in chart termsapp-interaction-patterns— the responsiveness expectationsmotion-and-transitions— animation budget