Keep getComputedStyle() out of callbacks that fire often: cache style values that only change on theme or breakpoint changes, read CSS custom properties once and refresh them on matchMedia changes, use entry sizes instead of computed widths, and let CSS (container queries, :has()) make style-dependent decisions itself.
Problem / Scenario Context
A charting library's ResizeObserver callback calls getComputedStyle(container) on every resize to read padding, font-size, a --chart-accent custom property and the container's width. During an animated sidebar collapse, the callback runs every frame for each of twelve charts, and profiles show Recalculate Style and Layout blocks nested in the callback, adding 6–10 ms per frame. None of the values except the width actually change during the animation.
getComputedStyle looks like a cheap property read. It is not, when anything has changed since the last style computation. The DOM Query Minimization topic covers layout reads; this page covers the style side.
Mechanics Explanation
getComputedStyle(el) returns a live CSSStyleDeclaration. Reading a property from it requires the browser to have up-to-date computed styles for that element:
- If styles are clean, the read is cheap.
- If anything invalidated style — a class change, an inline style write, an attribute used in a selector — the browser must recalculate style (for the affected subtree) synchronously: a forced Recalculate Style.
- For layout-dependent properties —
width,height,topwhen not specified, percentages resolved to pixels — it must also run layout: a forced Layout.
In a callback that also writes (chart redraw updates DOM or classes), and especially when several charts' callbacks run in the same batch, each chart's read comes after the previous chart's writes. That is the style-and-layout thrash seen in the profile.
Most style values that code reads in observer callbacks do not change between callbacks: padding, font, colours, custom properties set by a theme. They change on theme switches, breakpoint changes or class toggles you control — rare, discrete events that can invalidate a cache.
Comparison Table: Replacements for Common Reads
| Read in the callback | Changes when | Replacement |
|---|---|---|
width, height |
every resize | entry.contentBoxSize[0] |
padding-*, border-* |
theme/breakpoint | cache; or borderBoxSize − contentBoxSize |
font-size, font-family |
theme/breakpoint | cache; refresh on matchMedia / theme event |
CSS custom property (--accent) |
theme switch | cache; refresh on theme change |
display / visibility |
your own toggles | track in JS state |
| Whether a breakpoint applies | viewport or container size | matchMedia listener or container query |
Minimal Reproducible Example
const ro = new ResizeObserver((entries) => {
for (const e of entries) {
const cs = getComputedStyle(e.target);
const w = parseFloat(cs.width); // layout-dependent
const pad = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight);
const accent = cs.getPropertyValue('--chart-accent').trim();
redraw(e.target as HTMLElement, w - pad, accent); // writes DOM
}
});
charts.forEach((c) => ro.observe(c));
declare const charts: HTMLElement[];
declare function redraw(el: HTMLElement, w: number, accent: string): void;
Production-Safe Solution
interface ChartStyle { padX: number; font: string; accent: string }
const styleCache = new WeakMap<Element, ChartStyle>();
function readStyle(el: Element): ChartStyle {
const cs = getComputedStyle(el);
return {
padX: parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight),
font: `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`,
accent: cs.getPropertyValue('--chart-accent').trim(),
};
}
function styleOf(el: Element): ChartStyle {
let s = styleCache.get(el);
if (!s) { s = readStyle(el); styleCache.set(el, s); }
return s;
}
// Invalidate only on the events that can change these values.
const invalidateAll = () => charts.forEach((c) => styleCache.delete(c));
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', invalidateAll);
matchMedia('(min-width: 900px)').addEventListener('change', invalidateAll);
document.addEventListener('themechange', invalidateAll); // your app's theme toggle
const ro = new ResizeObserver((entries) => {
// Read phase: sizes from entries, styles from cache (reads happen only on a miss).
const jobs = entries.map((e) => ({
el: e.target as HTMLElement,
width: e.contentBoxSize[0].inlineSize, // already excludes padding
style: styleOf(e.target),
}));
// Write phase.
for (const j of jobs) redraw(j.el, j.width, j.style.accent);
});
charts.forEach((c) => ro.observe(c));
The width now comes from the entry — and contentBoxSize already excludes padding, so the padding read is only needed for other layout maths. Style values are read once per chart and on genuine changes. All reads that do happen occur in the read phase, before any chart writes, so even a cache miss costs one clean style read rather than a forced recalculation.
Letting CSS Decide Instead
Many callbacks read computed style only to make a decision that CSS could make itself:
- "Is the chart narrow enough to hide the legend?" A container query on the chart wrapper hides it with no script:
@container (width < 420px) { .legend { display: none; } }. - "Which colour should the line be?" Canvas and SVG drawn by script need a colour value, but SVG strokes can reference
currentColororvar(--chart-accent)directly, so the script does not need to know the value at all. - "Is the element visible?" Track visibility in JavaScript state when your code controls it, or use an
IntersectionObserverinstead of readingdisplay.
Each decision moved to CSS removes a read from the hot path entirely and usually removes a code path that could go stale.
Verification Steps
- Record a trace during a container animation and confirm no Recalculate Style or Layout nested in callbacks.
- Switch theme mid-session and confirm charts pick up new colours (cache invalidation works).
- Cross a breakpoint and confirm cached padding and fonts refresh.
- Search callbacks for
getComputedStyleand move each call into a cached or read-phase helper. - Compare frame times during resize before and after.
Common Mistakes to Avoid
- Reading
widthfrom computed style. It forces layout; entries have the size. - Calling
getComputedStyleper entry after writes. Every call after a write forces style work. - Caching without invalidation. Theme and breakpoint changes then show stale values.
- Script decisions CSS could make. Container queries and
var()remove the read entirely.
FAQ
Is getComputedStyle always expensive?
No. When styles are clean, reading a property is fast. It becomes expensive when something has invalidated style or layout since the last computation, which is typical inside callbacks that also write to the DOM.
Does reading a custom property force layout?
Reading a custom property forces style recalculation if styles are dirty, but not layout, because custom properties are resolved at style time. Layout is forced only by properties whose computed values depend on geometry.
How do I know when cached styles are stale?
List the events that can change them — theme switches, breakpoint media queries, class toggles your code makes — and invalidate on exactly those. Values that depend on container size are better read from entries or handled with container queries.
Can I get padding without getComputedStyle?
For horizontal padding plus border, subtract contentBoxSize from borderBoxSize on a ResizeObserver entry. For individual sides you still need computed style, which is a good candidate for caching.
Is the returned CSSStyleDeclaration safe to keep?
It is live: keeping it and reading from it later reads current values, with the same costs. Cache the extracted values, not the declaration object.
What about getComputedStyle in IntersectionObserver callbacks?
The same rules apply. Those callbacks run in a task after paint, so styles are usually clean at the start; reads become expensive after the callback, or an earlier callback in the batch, has written.
Related
- Using entry.boundingClientRect Instead of Re-Querying — the layout equivalent
- Reducing Layout Thrashing with ResizeObserver — the broader thrashing problem
- Keeping D3 Charts Responsive with ResizeObserver — charts that follow their container
↑ Back to DOM Query Minimization