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.
What it compares
Section titled “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)
Section titled “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 |
- Distinct
CSSStyleSheetobjects 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. - The one-sheet variants mount fastest.
adoptedtakes 32 ms againstlinkat 88 ms, n=3000, a 1.5–2.7× spread.linkis slowest: it adds a node and the resource-load machinery per element. - 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:linkadds N,styleadds 2N.usedJSHeapSizebarely moves and misses it, which is why the harness reports UA memory andnodes. - FOUC is the real differentiator. FOUC is a brief flash of unstyled
content while a stylesheet is still loading. The fetched
adoptedvariant 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, andlinkdo not shift. On a real network the gap widens, so localhost understates it. - Non-performance:
linkre-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.
Rerun it yourself
Section titled “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.
node bench/run.mjs # all variants × n 50,200,500node bench/run.mjs --n 500,3000 --classes 800 # the sample run abovenode bench/run.mjs --variants link,adopted # a subsetBENCH_HEADED=1 node bench/run.mjs # watch the browserIf you have no system Chrome:
npx playwright install chromiumBENCH_CHROME="$(node -e "console.log(require('playwright-core').chromium.executablePath())")" node bench/run.mjsEverything 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.