A page can return 200 OK and still show a blank screen. Its HTML may already be arriving while a stylesheet delays the first render, a script prevents the parser from advancing, or the main thread stays busy before the browser can produce a frame. Those failures look similar to a visitor but require different fixes.
The useful mental model is not simply "HTML, then CSS, then JavaScript." The browser discovers resources while parsing, builds representations of the document and its styles, and repeatedly performs the work needed to put pixels on screen. Some of that work overlaps. Some can be skipped for a particular update.
This article starts after the document response begins arriving. For DNS, connections, redirects, and HTTP timing, see what happens when you open a website. Here the question is narrower: what keeps received content from becoming visible, and how do you find the responsible stage?
The pipeline is a model, not a rigid checklist
A simplified first-render path looks like this:
HTML bytes -> HTML parser -> DOM -----------+
| -> styles -> layout -> paint -> composite -> screen
CSS bytes -> CSS parser -> CSSOM ----------+
^
JavaScript may pause parsing or change the DOM and styles
The DOM (Document Object Model) represents the document's nodes and relationships. The CSSOM (CSS Object Model) represents parsed CSS rules. The browser resolves applicable styles for rendered content, determines the sizes and positions of boxes, records what needs drawing, and produces the final frame. This is the conventional critical rendering path model, described by MDN and web.dev.
But a browser does not necessarily wait for every byte of HTML before showing anything. HTML parsing is incremental, resource discovery can start during parsing, and subsequent resource arrivals or DOM changes can produce additional rendering work. A page also need not repeat every stage for every visual update.
Parsing creates the DOM and discovers work
Consider a document that contains a heading, a stylesheet, an image, and a script. As the HTML parser encounters these elements, it adds nodes to the DOM and can initiate requests for referenced resources. Browsers also use a speculative preload scanner to discover some resources ahead of the main parser when parsing is blocked. Resource discovery is therefore not synonymous with finishing the DOM.
The DOM is not a screenshot. It includes elements that may never draw a box, including a display: none element. Likewise, an image element can exist in the DOM before its image data has finished downloading or decoding.
CSS rules are not painted pixels
CSS is parsed into the CSSOM; then the browser applies the cascade and computes styles in the context of the document. An ordinary stylesheet that applies to the current media conditions can delay rendering until the necessary styles are available. That is render-blocking, not the same as stopping the HTML parser.
These two declarations illustrate why DOM presence and visual output differ:
.removed-from-layout { display: none; }
.invisible-but-sized { visibility: hidden; }
Both elements can remain in the DOM. The first generates no layout box. The second generally participates in layout but is not painted visibly. This difference matters when you debug apparently missing content or unexpected empty space. See MDN's browser-rendering explanation.
JavaScript can interrupt the journey
A classic external <script src="app.js"></script> encountered by the parser normally pauses HTML parsing until the script is fetched and executed. A parser-blocking script can also have to wait for already-discovered blocking stylesheets, because it might inspect computed styles.
defer changes that relationship for an external classic script: downloading can proceed while HTML is parsed, and execution waits until parsing finishes. async also allows downloading without stopping the parser, but the script executes when ready rather than in document order. A normal type="module" script is deferred by default. These attributes do not make JavaScript execution free or guarantee a fast first paint. The web.dev resource-loading guide details the distinctions.
What layout, paint, and compositing actually do
After styles have been resolved, layout calculates box geometry. It answers questions such as how wide a paragraph is, where the next block starts, and whether a child overflows its container. The browser's internal rendering structures vary by engine, so a "render tree" is best treated as a useful abstraction rather than a promise about identical implementation details in every browser.
Paint records or rasterizes visual content such as backgrounds, borders, glyphs, and shadows. Compositing combines rendered layers into a frame for display. This is why "paint" is not always the final browser operation, and why changing a transform or opacity can sometimes update a composited layer without recalculating layout or repainting its content.
It is also why the phrase "every DOM change causes a full reflow" is false. Different changes invalidate different work:
| Change | Work you should investigate |
|---|---|
| Changing an element's width | Style and layout, followed by paint/compositing as needed |
| Changing its background color | Style and paint, usually without layout |
| Animating a transform | Potentially compositing after setup; do not assume no paint in every case |
| Changing a text node | Layout if line breaks or dimensions change, and drawing updates |
Adding display: none | A change to box generation that can affect layout |
The exact work depends on containment, dependencies, browser implementation, and whether a layer already exists. Adding will-change everywhere is not a shortcut; extra layers carry memory and management costs. These distinctions follow the rendering model in MDN's browser guide, not a universal cost table for every page.
A small experiment you can trace yourself
Save this as render-lab.html and open it in Chrome. The example is intentionally self-contained: there are no external stylesheets, images, libraries, or API calls, so resource timing is less likely to distract from the rendering stages.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Browser rendering lab</title>
<style>
body { font: 16px system-ui, sans-serif; padding: 2rem; }
.box { width: 120px; height: 80px; background: steelblue; }
.box.wide { width: 220px; }
.box.moved { transform: translateX(70px); }
.hidden { visibility: hidden; }
.gone { display: none; }
button { margin: 1rem 0.5rem 1rem 0; }
</style>
</head>
<body>
<h1>Render stages</h1>
<div id="box" class="box"></div>
<button id="resize">Toggle width</button>
<button id="move">Toggle transform</button>
<p id="hidden" class="hidden">I occupy space.</p>
<p id="gone" class="gone">I do not generate a box.</p>
<script>
const box = document.querySelector('#box');
document.querySelector('#resize').addEventListener('click', () => {
box.classList.toggle('wide');
});
document.querySelector('#move').addEventListener('click', () => {
box.classList.toggle('moved');
});
for (const id of ['hidden', 'gone']) {
const element = document.getElementById(id);
console.log(id, 'boxes:', element.getClientRects().length);
}
</script>
</body>
</html>
Open DevTools → Console. The hidden paragraph should report a layout rectangle, while gone should report zero rectangles. This checks box generation, not whether a node exists in the DOM. Both nodes are still present in the Elements panel. Browser-specific details such as fragmented inline boxes can affect rectangle counts in other examples; these two block paragraphs keep the comparison simple.
Now inspect the browser's actual work rather than assuming the cost from the CSS property name:
- Open DevTools → Performance and use Record and reload to capture the initial navigation. Examine the screenshots and the Main track for parsing, style, layout, and paint activity.
- Record a second trace while clicking Toggle width once. Look for style recalculation and a Layout event associated with that interaction.
- Record a third trace while clicking Toggle transform once. Compare the work shown. In Chrome this may be cheaper than changing width, but the event list depends on the engine, layer state, and capture settings.
- Keep the viewport, throttling settings, and browser state the same between captures. These are separate experiments, not benchmark results from this article.
For the controls and trace terminology, see the official Chrome Performance panel reference and Rendering tab. If you want to see which elements are repainted, enable Paint flashing in the Rendering tab. A flashing region shows paint invalidation; it is not a direct measurement of how long the user waited for the page to become interactive.
Distinguish resource delays from main-thread delays
Use the Network panel and Performance panel together. If a page is blank, first ask whether the resources necessary for a first render have arrived. Then inspect whether the main thread had time to process and draw them.
| Symptom in a trace | Next question |
|---|---|
| Stylesheet request finishes late, before first content appears | Is a needed stylesheet render-blocking? Could unused CSS be separated without a flash of incorrect styling? |
| HTML parser pauses near a classic script | Is it intentionally synchronous? Would defer, a module, or a different dependency order preserve behavior? |
| Resources arrive promptly, but long scripting tasks dominate | Which JavaScript tasks delay rendering or respond slowly to input? |
| Repeated Layout events after an interaction | Are scripts repeatedly reading geometry after changing styles, or are layout dependencies unusually broad? |
| Paint or layer work dominates | Is the visual update expensive to draw or are large layers being managed unnecessarily? |
Avoid interpreting DOMContentLoaded or load as proof that the user has seen usable content. They are document and resource lifecycle milestones, not a guarantee of first paint, complete hydration, or responsiveness. A trace connects the visible frames and main-thread activity more directly than those event timestamps alone.
A page can also be visible before client-side JavaScript finishes setting up interactions. In a server-rendered React application, for example, HTML rendering and client hydration are distinct concerns. If the content looks correct but React reports a mismatch, use the separate hydration mismatch troubleshooting guide.
What this model leaves out
The example does not simulate a slow network, CSS downloads, fonts, image decoding, third-party scripts, or a production application. It is a rendering exercise, not a performance benchmark. The recorded sequence may differ across Chrome versions, hardware, cache state, and operating systems. Other browser engines organize rendering work differently.
For deeper decisions about which resources delay the first visible frame, continue with render-blocking resources and their tradeoffs. For an actual page investigation, start with one reproducible symptom, capture a trace, and identify the stage that is busy. The pipeline becomes useful when it helps you explain a particular delay, not when you memorize five labels.
