Skip to content
academia.sh

Lesson 23 / 23

Style Performance

Selector matching being done right to left, invalidation scope depending on selector shape, a stylesheet blocking paint, and computing the critical style subset.

Contents

The previous five lessons took up how style is organized, and each answered the same question from a different place. There is one more question, and the browser asks it.

The stylesheet is downloaded, parsed, which rules match each element is found, and computed values are produced. This work has a cost, and where the cost comes from also shapes writing decisions. This lesson establishes that cost as a rule, and closes the course.

Matching Is Done Right to Left

The question of whether a selector matches an element can be answered in two directions. Going left to right — finding the outermost selector first and moving down the subtree — fits the reading direction. Browsers do not do this; they go right to left.

The reason is this: the question is not “which elements does this selector match” but “which rules match this element.” Once the element is in hand, if the selector’s rightmost component — the key selector — does not match the element, the rule is eliminated immediately. If it does match, the walk proceeds toward the ancestors.

// style-performance.mjs — selector matching direction and computing the critical style subset

// --- a small document tree ---
const node = (type, classes, children = []) => ({ type, classes, children, parent: null });
const DOCUMENT = node("body", [], [
  node("header", ["masthead"], [
    node("h1", ["masthead__name"]), node("p", ["masthead__location"]),
    node("nav", ["nav"], [node("ul", [], [node("li", [], [node("a", ["active"])]), node("li", [], [node("a", [])])])]),
  ]),
  node("main", [], [
    node("section", ["measurements-section"], [
      node("h2", ["heading"]),
      node("div", ["measurement-cards"], [
        node("article", ["measurement-card"], [node("h3", ["measurement-card__heading"]), node("p", ["measurement-card__value"])]),
        node("article", ["measurement-card"], [node("h3", ["measurement-card__heading"]), node("p", ["measurement-card__value"])]),
        node("article", ["measurement-card", "measurement-card--wide"], [node("h3", ["measurement-card__heading"]), node("p", ["measurement-card__value"])]),
      ]),
    ]),
    node("section", ["location-section"], [node("h2", ["heading"]), node("p", []), node("p", [])]),
  ]),
  node("footer", ["source-note"], [node("p", [])]),
]);

function link(n, parent = null) {
  n.parent = parent;
  for (const c of n.children) link(c, n);
  return n;
}
link(DOCUMENT);

const allNodes = (n, list = []) => { list.push(n); for (const c of n.children) allNodes(c, list); return list; };
const NODES = allNodes(DOCUMENT);

// --- compound selector check (type and class) ---
let checks = 0;
function matches(n, compound) {
  checks++;
  const type = compound.match(/^[a-z][\w-]*/);
  if (type && n.type !== type[0]) return false;
  for (const cls of compound.match(/\.[\w-]+/g) || []) if (!n.classes.includes(cls.slice(1))) return false;
  return true;
}

// right to left: key selector first, then ancestors
function rightToLeft(selector) {
  const parts = selector.trim().split(/\s+/);
  checks = 0;
  let matched = 0;
  for (const n of NODES) {
    if (!matches(n, parts[parts.length - 1])) continue;
    let i = parts.length - 2, ancestor = n.parent, done = i < 0;
    while (i >= 0 && ancestor) {
      if (matches(ancestor, parts[i])) i--;
      ancestor = ancestor.parent;
      if (i < 0) done = true;
    }
    if (done) matched++;
  }
  return { checks, matched };
}

// left to right: first selector first, then descend into subtrees
function leftToRight(selector) {
  const parts = selector.trim().split(/\s+/);
  checks = 0;
  let matched = 0;
  const descend = (n, i) => {
    for (const c of allNodes(n).slice(1)) {
      if (!matches(c, parts[i])) continue;
      if (i === parts.length - 1) matched++;
      else descend(c, i + 1);
    }
  };
  for (const n of NODES) {
    if (!matches(n, parts[0])) continue;
    if (parts.length === 1) matched++;
    else descend(n, 1);
  }
  return { checks, matched };
}

console.log(`node count in the document: ${NODES.length}`);
console.log("\n--- check count for matching the same selector in two directions ---");
console.log("selector".padEnd(62) + "right-to-left".padStart(14) + "left-to-right".padStart(14) + "  matched");
for (const s of [
  ".measurement-card__heading",
  ".measurements-section .measurement-cards .measurement-card .measurement-card__heading",
  "body main section div article h3",
  ".masthead__name",
]) {
  const rl = rightToLeft(s), lr = leftToRight(s);
  console.log(s.padEnd(62) + String(rl.checks).padStart(14) + String(lr.checks).padStart(14) + String(rl.matched).padStart(10));
}

// --- critical style: rules matching elements visible on first screen ---
const RULES = [
  { selector: ".masthead", bytes: 96 }, { selector: ".masthead__name", bytes: 64 },
  { selector: ".nav", bytes: 88 }, { selector: ".nav a", bytes: 72 },
  { selector: ".measurement-cards", bytes: 120 }, { selector: ".measurement-card", bytes: 148 },
  { selector: ".measurement-card__heading", bytes: 58 }, { selector: ".measurement-card__value", bytes: 86 },
  { selector: ".measurement-card--wide", bytes: 44 }, { selector: ".location-section", bytes: 92 },
  { selector: ".location-section p", bytes: 60 }, { selector: ".source-note", bytes: 54 },
  { selector: ".measurement-filter", bytes: 132 }, { selector: ".sidebar", bytes: 104 },
];

// The section visible on the first screen: masthead and the measurements section (first 20 nodes in document order).
const VISIBLE = new Set(NODES.slice(0, 20));
const anyMatches = (selector) => {
  const parts = selector.trim().split(/\s+/);
  return [...VISIBLE].some((n) => {
    if (!matches(n, parts[parts.length - 1])) return false;
    let i = parts.length - 2, ancestor = n.parent;
    while (i >= 0 && ancestor) { if (matches(ancestor, parts[i])) i--; ancestor = ancestor.parent; }
    return i < 0;
  });
};

const critical = RULES.filter((r) => anyMatches(r.selector));
const total = RULES.reduce((t, r) => t + r.bytes, 0);
const criticalBytes = critical.reduce((t, r) => t + r.bytes, 0);

console.log("\n--- the critical style subset ---");
console.log(`  node count visible on first screen: ${VISIBLE.size} / ${NODES.length}`);
console.log(`  critical rules: ${critical.length} / ${RULES.length}`);
console.log(`  critical bytes: ${criticalBytes} / ${total}  (%${((criticalBytes / total) * 100).toFixed(1)})`);
console.log("  non-critical:", RULES.filter((r) => !critical.includes(r)).map((r) => r.selector).join(", "));
node count in the document: 29

--- check count for matching the same selector in two directions ---
selector                                                       right-to-left left-to-right  matched
.measurement-card__heading                                                29            29         3
.measurements-section .measurement-cards .measurement-card .measurement-card__heading            38            55         3
body main section div article h3                                          44           102         3
.masthead__name                                                           29            29         1

--- the critical style subset ---
  node count visible on first screen: 20 / 29
  critical rules: 8 / 14
  critical bytes: 732 / 1218  (%60.1)
  non-critical: .measurement-card--wide, .location-section, .location-section p, .source-note, .measurement-filter, .sidebar

Four selectors are tried on a 29-node document. For single-class selectors, the two directions run the same number of checks: every node is tested once.

The distinction shows up in long selectors. A four-part selector needs 38 checks right to left, 55 left to right; a six-part type-selector chain needs 44 versus 102. The right-to-left walk eliminates a node whose key selector does not match in a single check; the left-to-right walk visits the entire subtree for every matching starting point.

The writing rule that follows from this is that the key selector should be distinguishing. A selector ending in ... h3 starts an ancestor walk at every heading in the document; a selector ending in ... .measurement-card__heading starts one at only three nodes.

A sense of scale is needed: selector matching is a small cost next to the layout and paint stages. The real cost of long selectors is not the match count, it is the invalidation scope in the next section.

Invalidation Scope

When an element’s class changes, the browser has to determine which elements’ style needs to be recomputed. This set is called the invalidation scope, and it is derived from the shape of the selectors.

The rule is this: if a class occurs only in rules matching its own element, the scope is that element. If the same class occurs to the left of a descendant combinator, the scope is the element’s entire subtree.

.measurement-card--warning { border-color: var(--warning); }          /* scope: the element itself */
.measurement-card--warning .measurement-card__value { color: var(--warning); }  /* scope: the whole subtree */

In the second notation, adding the class to the card forces every element inside the card to have its style recomputed. The custom-property notation built in an earlier lesson lowers this cost too: the modifier writes only a value, and the descendants are already reading that value.

Selectors working in the ancestor direction widen the scope upward: in notations where an element’s state also affects its parent, the change’s result travels up into the tree. Such selectors are powerful and are used when needed; used without measure, they make the invalidation scope unpredictable.

A Stylesheet Blocks Paint

A document’s first paint waits until the stylesheet is downloaded and parsed. The reason is clear: a page painted before style is known would be repainted from scratch once style arrives, and the user would see an unstyled page.

This has three consequences. A stylesheet is declared in the document’s head; declaring it at the end delays parsing from proceeding. A file carrying a media attribute whose condition is not met does not block paint; print style is separated this way. An @import rule that calls another file from inside a stylesheet, on the other hand, puts the two downloads in sequence; the second request starts only after the first file is parsed.

Critical Style

The rules needed for the first paint are not all of the rules in the file. Only rules matching elements visible on the first screen are needed; the rest can load later.

The output’s second block computes this subset: when the first twenty of the twenty-nine nodes are taken as visible, eight of fourteen rules turn out to be needed. In bytes, that is 60 percent of the total.

The critical subset is written directly into the document; the remaining rules load in a way that does not block paint. This notation has two costs. The first is repetition: the critical rules exist both in the document and in the file. The second is staleness; when the page structure changes, the critical set has to be recomputed, and it cannot be maintained by hand.

For this reason, critical style is a generated output, not a hand-written one — its computation is exactly as mechanical as above: given the set of visible elements and the rule list, the subset is determined.

Layers and Maintenance Cost

The @layer rule’s runtime cost is small, but there is an ordering rule: layer order is set where the layer names first appear. This is why layer names are declared on a single line at the top of the stylesheet:

@layer base, layout, components, utilities;

This line fixes the order; which file loads in which order no longer changes the result. What it gains is not a performance measure but avoiding an unmeasurable cost — specificity creep and !important chains.

Utility classes sit in the topmost layer; this lets a single-declaration class override a component rule without a specificity trick. This is the arrangement needed for the fifth lesson’s mixed use to actually work.

Summary

  • Selector matching is done right to left; nodes whose key selector does not match are eliminated in a single check, which is why a distinguishing key selector makes long selectors cheap too.
  • The real cost of long selectors is not the match count but the invalidation scope: if a class occurs to the left of a descendant combinator, the scope is the entire subtree.
  • A stylesheet blocks first paint; a media declaration whose condition is not met does not block it, while @import puts downloads in sequence.
  • Critical style is the subset of rules matching elements visible on the first screen; its computation is mechanical and, because it cannot be maintained by hand, it must be generated.
  • Layer order is set where the names first appear; declaring it in a single line up front makes load order independent of the result.

Course Wrap-Up

This course started with layout. Flexbox distributed empty space on a single axis, grid defined lines on two axes, and the choice between the two was tied to a criterion. Responsive design derived breakpoints not from device lists but from content itself; media queries asked about the viewport, container queries asked about the component’s own context, and user preferences became an input to the design.

The motion topic separated geometry from time: a transform matrix determined what moved where, a timing curve determined how long it took. The rendering pipeline showed which declaration ran which stage, and it became clear that motion also carries a cost that reaches the user.

The last topic asked where all of this should be written. A naming methodology kept specificity flat, custom properties gave a component an interface, compile tools organized the source, scoping approaches moved names from convention into mechanism, and utility classes embedded the constraint into the system itself. The North Slope Measurement Station page moved one step further in each of these lessons, and ended up with a style layer split into component files, a scale definition, and a layer order.

There is one thing the page still cannot do: it does not do anything itself. Measurement cards show up because they are written in the document, the status indicator changes only when an attribute is edited by hand, the filter filters nothing. Modifying the document tree with a program, capturing user actions as events, and using the interfaces the browser offers are the subject of the next course: The Browser and the Web Platform. There, the document stops being formatted text and becomes a data structure that is worked on.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close