Under the Hood
Rendering

Parsing CSS into the CSSOM

While the DOM is being assembled, the browser is running CSS bytes through a parallel pipeline into the CSSOM — but unlike HTML parsing, this one blocks the first paint outright, and understanding why turns into a concrete set of loading optimizations.

Parsing CSS into the CSSOM

The previous lesson followed HTML bytes into a tree that tolerates almost anything you throw at it. CSS bytes go through a structurally similar two-step journey — tokenize, then build a structured model — but the parser that handles them behaves nothing like its HTML counterpart in the one way that matters most: it gets to stop the whole page from appearing.

The frame for this lesson: CSS bytes are tokenized and parsed into the CSSOM, a structured model of stylesheets, the rules inside them, and the selector-plus-declaration pairs inside those rules — and because the render tree cannot be built without it, the browser refuses to paint until every render-blocking stylesheet has been fully parsed. That single constraint is the reason a handful of CSS loading tricks exist at all.

Tokenizing, then building the CSSOM

CSS parsing starts the same way HTML parsing does: a tokenizer walks the byte stream (already decoded to characters) and emits tokens — identifiers, strings, numbers, punctuation like {, }, :, ;, and @-keywords. From there, the parser consumes the token stream and builds the CSSOM — the CSS Object Model — which mirrors the actual nesting of a stylesheet: a StyleSheet contains a list of rules, and each rule pairs a selector (.card, #nav > li, h1, h2) with a declaration block (the property: value pairs between the braces).

.card {
  color: var(--ink);
  padding: 1rem;
}

.card:hover {
  color: var(--terracotta);
}

Parsed, that becomes a stylesheet holding two rule objects: one for .card with two declarations, one for .card:hover with one. Nothing here has touched the DOM yet — the CSSOM at this stage doesn't know or care which elements exist, only what the rules say. It's a structured, queryable representation of the stylesheet itself, which is exactly why it needs to be a real object model rather than a flat token list: the next lesson, selector matching and the cascade, has to walk this structure repeatedly, testing each rule's selector against elements in the DOM, and a tree of rule objects is what makes that lookup tractable.

That matching step — deciding which rules actually apply to which elements, and resolving conflicts between rules that both match — is deliberately out of scope here. This lesson stops at "the rules exist as a structured model"; lesson 4 covers "here's how the browser decides which of them wins."

Why CSS parsing blocks the first paint

The flagship lesson already stated this as a rule; here's the mechanical reason underneath it. The render tree pairs every visible DOM node with its computed style, and a computed style cannot exist until the browser knows every rule that could possibly apply to that node. That means the render tree — and therefore layout, and therefore paint — cannot start until the CSSOM is complete, not partially built. There's no safe way to paint with "the rules parsed so far": a rule three-quarters of the way through the stylesheet might override something already used to paint an element above it, and repainting after the fact would produce exactly the flash of unstyled content (FOUC) the browser is trying to avoid.

So the browser treats CSS as render-blocking by default: any <link rel="stylesheet"> without a disqualifying media attribute holds up the render tree — and therefore the first paint — until it has finished downloading and parsing. This is why stylesheets belong in the <head>, discovered as early as possible: every millisecond before the browser starts fetching your CSS is a millisecond added to the earliest possible paint, no matter how fast everything else on the page is.

The media-query escape hatch

A <link> tag's media attribute isn't just for scoping styles to a condition — it also tells the browser whether that stylesheet can possibly matter for the current render. A stylesheet whose media query doesn't match the current environment is still fetched (so it's ready instantly if the condition ever becomes true), but it is not render-blocking, because the browser can prove in advance that none of its rules will affect what's about to be painted.

<!-- Render-blocking: always applies, so the browser must wait for it -->
<link rel="stylesheet" href="main.css" />

<!-- NOT render-blocking: this only matters when the page is printed,
     which isn't "right now" -->
<link rel="stylesheet" href="print.css" media="print" />

<!-- NOT render-blocking on a wide viewport, because the max-width
     condition is false right now -->
<link rel="stylesheet" href="narrow.css" media="(max-width: 600px)" />

That's a real, practical lever: split CSS that only applies under specific conditions — print styles, styles gated behind a narrow viewport — behind a media attribute, and the browser can paint the initial view without waiting on bytes it can already tell it doesn't need yet. Note the asymmetry — if the viewport is later resized to match (max-width: 600px), the browser applies that already-downloaded stylesheet immediately, with no new network trip. You only avoided blocking the first paint, not the download itself.

The @import penalty

@import lets one stylesheet pull in another from inside its own CSS:

/* main.css */
@import url("base.css");

.card {
  color: var(--ink);
}

The problem is discovery order. The browser can't know base.css needs to be fetched until it has downloaded and started parsing main.css far enough to hit the @import rule — the reference is buried inside the file's own bytes, not visible from the HTML. That turns what could have been two parallel downloads into a serial round trip: fetch main.css, parse enough to find the @import, then start fetching base.css, and the render tree still can't proceed until both are fully parsed. Stack a few @imports (or import chains, where an imported sheet imports another) and you've built a waterfall entirely invisible to the browser's usual preload scanner, which reads the HTML, not the inside of CSS files.

<link rel="stylesheet"> tags in the HTML don't have this problem, because the preload scanner discovers all of them directly from the markup and can request every one in parallel the moment it sees them. That's the practical reason @import is discouraged for anything on the critical path: prefer separate <link> tags, which the browser can start fetching immediately and simultaneously, over @import, which can only be discovered serially, after the fact.

The CSSOM is scriptable

Like the DOM, the CSSOM isn't just an internal implementation detail — it's exposed to JavaScript. document.styleSheets gives you a live list of the parsed stylesheets, and each one exposes its rules:

// Walk every parsed stylesheet and log its rules
for (const sheet of document.styleSheets) {
  for (const rule of sheet.cssRules) {
    console.log(rule.selectorText, rule.style.cssText);
  }
}

// Insert a new rule directly into the CSSOM, no new <style> tag needed
document.styleSheets[0].insertRule(
  ".highlight { background: var(--sage-wash); }",
  0
);

Reading document.styleSheets is reading the very structure this lesson describes — rules, selectors, declaration blocks — after the parser has already built it. insertRule and deleteRule mutate that structure directly, which is exactly how tools like browser devtools let you edit CSS live and see it take effect without a page reload.

Where this goes next

The CSSOM this lesson builds is only half of what the render tree needs — a pile of parsed rules doesn't tell you which ones apply to which element, or which one wins when two rules disagree about the same property on the same node. Selector matching and the cascade is where the browser actually walks the DOM against this structure and resolves that question, rule by rule, element by element.

Go deeper

  • MDN — CSSStyleSheet and the CSSOM The scriptable interface to the exact structure this lesson describes — stylesheets, cssRules, insertRule, deleteRule.
  • web.dev — Render-blocking CSS The authoritative treatment of why CSS blocks rendering and how the media attribute changes that, straight from the source this lesson's optimizations draw on.
  • MDN — @import The @import syntax and browser support notes, including the performance guidance behind preferring <link>.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. What are the two phases of CSS parsing, and what structure does the second phase produce?
  2. What does a CSSOM rule object actually pair together, and what does the CSSOM deliberately not yet know about the DOM?
  3. Why can't the browser start building the render tree from a partially-parsed CSSOM, even just to get a head start?
  4. A stylesheet is linked with media="print". Is it downloaded? Is it render-blocking for a normal screen view? Explain the difference.
  5. Walk through why @import creates a serial round trip that separate <link> tags avoid, referencing what the preload scanner can and can't see.
  6. What does document.styleSheets[0].insertRule(...) actually modify, in terms of the structure this lesson describes?
  7. Why is 'which rules apply to which element' explicitly left out of this lesson, and which lesson picks it up?