# Artifact Performance

> Make a data page open fast and stay responsive - render budgets, the SVG-to-canvas threshold, event delegation instead of per-element listeners, virtualization, debouncing and rAF batching, avoiding layout thrash, and measuring rather than guessing. Trigger on "slow page", "janky", "laggy", "takes ages to open", "too many elements", "performance", "canvas vs svg", "optimize the page", "scroll stutter".

- Skill: `lukehle/artifact-performance` (Agent Skill)
- Install (CLI): `npx skillmds@latest add lukehle/artifact-performance`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lukehle/artifact-performance/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: Lukehle (https://skillmd.com/u/lukehle)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/lukehle/artifact-performance

---


# 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

```js
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:

1. **Too much embedded data** parsed on load → `artifact-data-loading`
2. **Too many DOM nodes** → aggregate, paginate, or virtualize
3. **Per-element event listeners** → delegate
4. **Layout thrash** — reading and writing layout in a loop
5. **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.

```js
// 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.

```js
// 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 |

```js
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.

```js
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 problems
- `svg-charting` — the SVG/canvas decision in chart terms
- `app-interaction-patterns` — the responsiveness expectations
- `motion-and-transitions` — animation budget

