# Benchmarks

> Measured cost of four CSS-delivery strategies at 3000 components, and how to reproduce the run.

Measured numbers for four ways of getting one utility stylesheet into many shadow roots, plus the command that reproduces them. The [stylesheets guide](/guides/stylesheets/) gives the recommendation these numbers support; this page is the evidence.

## What it compares

| Variant   | What each component does                                                        | Maps to                                   |
| --------- | ------------------------------------------------------------------------------- | ----------------------------------------- |
| `link`    | its own `<link rel="stylesheet">` in its shadow root                            | `?hmr-url`                                |
| `style`   | its own `<style>` with the CSS text inlined                                     | per-**type** `unsafeCSS(raw)` (see below) |
| `adopted` | adopts one shared constructed `CSSStyleSheet`, filled by a post-mount `fetch()` | `?css-sheet` / `urlSheet()`               |
| `inline`  | adopts one shared sheet, filled before mount                                    | inline shared module (`?raw`/`?inline`)   |

The `style` variant gives every *instance* its own `<style>`, to expose the per-parse cost. Lit caches `static styles` per component class, so one component type instantiated N times parses once. The `style` curve is therefore what you pay when the full sheet is inlined into many distinct component *types*, or inlined without caching. It is not a per-instance cost for one type.

The harness uses vanilla custom elements, not Lit. The variable under test is the platform primitive: a `<link>` or `<style>` per root against one shared `adoptedStyleSheets`. That is what Lit’s `static styles` uses underneath.

## Findings (sample run)

One machine, **n=3000, classes=800** (\~76 kB sheet), unthrottled CPU, localhost. Directional, not precise. Reproduced across runs unless noted.

| variant   | sheets | mount(ms) | nodes  | uaMem(MB) | fouc(ms) |
| --------- | ------ | --------- | ------ | --------- | -------- |
| `adopted` | **1**  | **32**    | 12 017 | **2.6**   | 12       |
| `inline`  | **1**  | 42        | 12 017 | 2.7       | 0        |
| `style`   | 3000   | 53        | 18 017 | 3.3       | 0        |
| `link`    | 3000   | 88        | 15 017 | 3.2       | 1        |

1. **Distinct `CSSStyleSheet` objects scale N against 1**, deterministically. But the engine shares the parsed CSS *contents* for identical sources: a same-URL `<link>`, or a byte-identical inline `<style>`. The per-element cost is N wrapper objects, N extra DOM nodes, and N per-root style scopes. It is not N parses.
2. **The one-sheet variants mount fastest.** `adopted` takes 32 ms against `link` at 88 ms, n=3000, a 1.5–2.7× spread. `link` is slowest: it adds a node and the resource-load machinery per element.
3. **Per-element delivery costs modestly more memory.** About 0.6 MB extra agent memory at 3000 elements, measured with `measureUserAgentSpecificMemory`. It comes from the wrapper objects and the extra DOM nodes: `link` adds N, `style` adds 2N. `usedJSHeapSize` barely moves and misses it, which is why the harness reports UA memory and `nodes`.
4. **FOUC is the real differentiator.** FOUC is a brief flash of unstyled content while a stylesheet is still loading. The fetched `adopted` variant mounts unstyled and styles about 10 ms later. In a DevTools trace that showed up as **CLS 0.42**, a failing Core Web Vital. `inline`, `style`, and `link` do not shift. On a real network the gap widens, so localhost understates it.
5. **Non-performance:** `link` re-renders the component on every HMR edit. The shared adopted sheet hot-swaps in place.

Net: share one sheet, `adopted` or `inline`. Fewest objects, fewest nodes, fewest bytes, fastest mount. Prefer the inline-shared path when first-paint stability matters, or `?css-sheet` with a `<link rel="preload">`. Plain `?css-sheet` trades a brief shift for an independently cacheable asset.

Numbers are machine-specific

Run on a quiet box, and compare variants from the *same* run. The backing data for this table, n=500 and n=3000 across all variants, is committed as [`bench/sample-results.json`](https://github.com/oddcelot/vite-plugin-lit/blob/main/bench/sample-results.json).

## Rerun it yourself

Requires Node ≥ 20 and a Chrome or Chromium. The runner uses Chrome through playwright-core’s `chrome` channel by default, the same setup as the e2e suite. There is no build step and no dev server. The runner serves the harness itself and generates the synthetic utility sheet on the fly.

```sh
node bench/run.mjs                              # all variants × n 50,200,500
node bench/run.mjs --n 500,3000 --classes 800   # the sample run above
node bench/run.mjs --variants link,adopted      # a subset
BENCH_HEADED=1 node bench/run.mjs               # watch the browser
```

If you have no system Chrome:

```sh
npx playwright install chromium
BENCH_CHROME="$(node -e "console.log(require('playwright-core').chromium.executablePath())")" node bench/run.mjs
```

Everything lands in `bench/results/`, which is git-ignored. You get `summary.json` with every row of metrics, and one `trace-<variant>-n<N>-c<C>.json` DevTools timeline trace per run. Open Chrome DevTools ▸ **Performance** ▸ **Load profile…** and pick a trace to inspect `ParseAuthorStyleSheet`, `UpdateLayoutTree` for style recalc, and `Layout` events directly.

| metric       | meaning                                                          |
| ------------ | ---------------------------------------------------------------- |
| `sheets`     | distinct `CSSStyleSheet` objects across all roots (the headline) |
| `parse(ms)`  | summed `ParseAuthorStyleSheet` self-time                         |
| `recalc(ms)` | summed style-recalc self-time                                    |
| `layout(ms)` | summed `Layout` self-time                                        |
| `mount(ms)`  | wall time to create + insert + flush layout for N elements       |
| `fcp(ms)`    | first-contentful-paint                                           |
| `heap(MB)`   | `usedJSHeapSize` after mount (Chrome-only, quantized)            |

Full harness documentation is in [`bench/README.md`](https://github.com/oddcelot/vite-plugin-lit/blob/main/bench/README.md).

## Related

[Choosing a delivery strategy](/guides/stylesheets/)What these numbers mean for your app.

[Share one stylesheet with ?css-sheet](/guides/stylesheets/css-sheet/)The fastest-mounting variant, with zero boilerplate.
