Skip to content

Benchmarks

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

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.

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.

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.

Terminal window
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:

Terminal window
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.