A browser does not paint HTML directly. It receives bytes, discovers resources, constructs several internal models, runs JavaScript between rendering opportunities, and eventually asks the operating system to display composited pixels. A slow page is therefore rarely explained by one vague instruction to “render.” The delay may come from a blocking stylesheet, expensive selector matching, unstable geometry, a large paint, too many GPU-backed layers, or main-thread JavaScript that prevents any of those stages from starting.

Understanding the pipeline turns performance work from folklore into diagnosis. The useful question is not merely “Is this page slow?” but “Which stage delayed the next useful frame, and what caused that stage to run?”

Parsing HTML and CSS into working models

The navigation begins with an HTTP response. The HTML parser decodes incoming bytes into characters, tokenizes them, and builds the Document Object Model (DOM). Streaming matters: the parser can create nodes and discover subresources before the full document arrives. Malformed markup is repaired according to defined parsing rules, so the DOM visible in DevTools may not exactly mirror the source text.

While parsing, the browser’s preload scanner can discover obvious resources such as stylesheets, scripts, fonts, and images ahead of the main parser. It improves network utilization, but discovery is not the same as execution. A classic script without defer pauses HTML parsing while it downloads and runs because it may call document.write() or inspect nodes parsed so far.

CSS follows a parallel path. Stylesheets are tokenized and parsed into the CSS Object Model (CSSOM), including selectors, declarations, cascade layers, media queries, and computed-value rules. The DOM says what elements exist; the CSSOM provides rules that may apply to them. Neither tree alone determines pixels.

html
<head>
  <link rel="stylesheet" href="/app.css" />
  <script type="module" src="/app.js"></script>
</head>
<body>
  <main class="article">
    <h2>Rendering has dependencies</h2>
    <img src="/diagram.webp" width="960" height="540" alt="Rendering pipeline" />
  </main>
</body>

Module scripts are deferred by default: they download in parallel and execute after parsing. Explicit image dimensions reserve geometry before the image bytes arrive. Those two details remove different bottlenecks: one avoids parser interruption, while the other avoids a later layout shift.

Note

The DOM is not the render tree. Elements such as head, nodes under display: none, and comments do not produce boxes. Pseudo-elements and generated content can produce boxes even though they are not ordinary DOM children.

From style calculation to composited pixels

Once enough DOM and CSSOM data exists, the browser calculates styles and creates a renderable box structure. Modern engines optimize and overlap work, so this is a dependency model rather than a promise that every document always performs one complete, serial pass.

flowchart LR A[HTML bytes] --> B[HTML parser] B --> C[DOM] D[CSS bytes] --> E[CSS parser] E --> F[CSSOM] C --> G[Style calculation] F --> G G --> H[Layout] H --> I[Paint records] I --> J[Raster tiles] J --> K[Compositor] K --> L[Screen pixels] M[JavaScript and user input] --> C M --> G

The major stages have distinct outputs and costs:

Stage Main question Typical triggers Result
Style Which declarations apply? Class changes, new rules, state and media-query changes Computed styles
Layout Where is each box and how large is it? Geometry, content, font, or viewport changes Box positions and dimensions
Paint What should each visual area look like? Color, shadow, text, border, and image changes Display list or paint records
Raster Which bitmap tiles represent those records? Newly visible or invalidated painted regions Texture tiles
Composite How are layers transformed and blended? Transform, opacity, scrolling, layer updates Final frame

Style calculation resolves inheritance, specificity, custom properties, and selectors. Layout uses the resulting box rules to determine geometry, including line wrapping and intrinsic sizes. A change near the top of a document can affect many descendants or later siblings, although engines use invalidation tracking to avoid recomputing everything.

Paint records drawing commands in visual order: backgrounds, text, borders, images, shadows, and effects. Rasterization converts relevant records into bitmap tiles, often using worker threads. The compositor combines layers, applies eligible transforms and opacity, accounts for scroll position, and submits the frame. Compositor-only animation can avoid layout and paint, but pixels still need memory, scheduling, and blending.

Render-blocking resources and first display

HTML can stream, yet the first display still depends on critical resources. Stylesheets are render-blocking by default because painting unstyled content and repainting it immediately would be visually disruptive. CSS can also delay a script that queries computed style. Large style bundles, redirect chains, and @import rules therefore lengthen the critical rendering path.

Classic synchronous scripts are parser-blocking. defer downloads a classic script in parallel, preserves document order, and runs it after parsing but before DOMContentLoaded. async downloads in parallel and runs as soon as ready, with no ordering guarantee. Modules are deferred by default, while async can also be applied to a module when independence is intentional.

Resource pattern Parsing behavior Execution or application Good fit
<script src="app.js"> Stops parser Immediately after download Rare legacy dependency
<script defer src="app.js"> Continues Ordered, after parse DOM-dependent classic scripts
<script async src="analytics.js"> Continues As soon as ready Independent analytics or ads
<script type="module"> Continues Deferred, dependency-aware Modern application entry
<link rel="stylesheet"> Continues parsing, blocks render After CSS is available Required page styling

Fonts and images add subtler dependencies. Text may initially use a fallback font and shift when the web font arrives. Use a deliberate font-display policy, preload only genuinely critical fonts, subset character ranges, and choose metrically compatible fallbacks. Give images width and height or an aspect-ratio; eagerly load the likely Largest Contentful Paint image, but lazy-load below-the-fold media.

Warning

Preloading everything does not make everything critical. Excessive preload competes for bandwidth with CSS, fonts, and the LCP image, and can make the first display slower. Priority should express actual user-visible dependency.

The event loop and the frame budget

Rendering shares the main thread with most JavaScript, DOM events, style work, layout, and paint preparation. The event loop takes a task, drains the microtask queue, and may then perform a rendering update before moving to another task. A chain of promises can monopolize the thread just as effectively as one long callback because microtasks are drained before the browser gets another rendering opportunity.

At 60 Hz, a display presents a frame roughly every 16.716.7 ms. At 120 Hz, the interval is about 8.38.3 ms. The application never owns that entire budget: the browser needs time for input processing, style, layout, paint, raster coordination, and compositing. Missing a deadline means the previous frame remains visible, producing stutter.

requestAnimationFrame (rAF) runs callbacks before the next paint and is appropriate for visual updates. It is not a magic performance wrapper: a 30 ms rAF callback still misses frames. Split CPU-heavy work into chunks, move suitable computation to a Web Worker, and yield between chunks. Use requestIdleCallback only for optional background work and provide a timeout when eventual progress matters; it may receive little or no idle time on a busy page.

ts
const pendingWidths = new Map<HTMLElement, number>();
let frameRequested = false;

export function scheduleWidth(element: HTMLElement, width: number): void {
  pendingWidths.set(element, width);
  if (frameRequested) return;

  frameRequested = true;
  requestAnimationFrame(() => {
    frameRequested = false;
    for (const [target, nextWidth] of pendingWidths) {
      target.style.width = `${nextWidth}px`;
    }
    pendingWidths.clear();
  });
}

This coalesces repeated writes into one visual update. For input handlers, do the minimum synchronous work, avoid unbounded microtask chains, and cancel obsolete asynchronous work. Responsiveness is about letting the browser reach rendering and input checkpoints regularly, not merely labeling code async.

Reflow, paint invalidation, layers, and the GPU

“Reflow” is the common name for layout after an invalidation. Changing text, dimensions, font metrics, grid tracks, or viewport size can invalidate geometry. Reading a layout-dependent property such as offsetWidth after writing styles may force the browser to flush pending style and layout synchronously so it can return an accurate value.

ts
// Bad: each write can make the following read force layout.
for (const card of cards) {
  card.style.width = `${container.clientWidth / 3}px`;
}

// Better: read once, then batch writes.
const cardWidth = container.clientWidth / 3;
requestAnimationFrame(() => {
  for (const card of cards) card.style.width = `${cardWidth}px`;
});

This read/write alternation is called layout thrashing. Prefer CSS Grid or Flexbox when layout can be expressed declaratively. When JavaScript measurements are necessary, group reads before writes and avoid repeated geometry queries inside loops. CSS containment can reduce the affected region: contain: layout paint tells the engine that an element’s internal layout and painting do not affect the outside in specified ways. content-visibility: auto can skip rendering work for off-screen sections while preserving an estimated intrinsic size.

Layers are another tradeoff, not a universal accelerator. The compositor can animate a promoted layer’s transform or opacity without repainting its contents. However, each layer consumes texture memory and may require rasterization, upload, and blending. will-change is a temporary hint for an imminent change, not a stylesheet default to apply everywhere.

The GPU excels at raster and composition workloads, but it cannot repair expensive DOM construction, selector matching, JavaScript, or layout. Animating transform: translate(...) usually costs less than animating left, because left changes geometry. Large filters, masks, transparency, and huge scaled layers can still be expensive even when they avoid layout.

css
.drawer {
  transform: translateX(-100%);
  transition: transform 180ms ease-out;
}

.drawer[data-open='true'] {
  transform: translateX(0);
}

Tip

Promote only after profiling. Apply will-change shortly before a known interaction and remove it afterward so the browser can release unnecessary layer resources.

Observe metrics, profile, and apply practical fixes

Polling layout in timers wastes work and often forces measurements at awkward times. Browser observers expose changes at better-defined points:

API Reports Typical use Important caution
ResizeObserver Element content or border size changes Responsive components, chart resizing Avoid feedback loops that resize the observed target repeatedly
IntersectionObserver Visibility relative to a root Lazy work, view tracking, list virtualization It is asynchronous and not pixel-perfect occlusion detection
PerformanceObserver Timeline entries Web Vitals, long tasks, resource timing Entry support varies; feature-detect types
MutationObserver DOM tree and attribute mutations Integration boundaries, editor tooling Broad subtree observation can produce large batches
ts
const supported = PerformanceObserver.supportedEntryTypes;

if (supported.includes('longtask')) {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      console.warn('Long task', Math.round(entry.duration), 'ms');
    }
  });
  observer.observe({ type: 'longtask', buffered: true });
}

Metrics should reflect what users experience. Largest Contentful Paint (LCP) measures when the largest qualifying visible content element renders; optimize its server response, resource discovery, priority, and render delay. Interaction to Next Paint (INP) summarizes interaction responsiveness across a visit; reduce long tasks, event-handler work, and the rendering work triggered by interactions. Cumulative Layout Shift (CLS) measures unexpected visual instability; reserve media and ad space, stabilize fonts, and do not insert content above what the user is reading.

Supporting signals explain causes: Time to First Byte, First Contentful Paint, long tasks, JavaScript execution time, style/layout duration, paint area, and resource waterfalls. Lab tools provide reproducibility, while real-user monitoring captures actual devices, networks, caches, and interactions. Use both; a fast development laptop is not a representative population.

Begin with a reproducible flow and a concrete symptom: slow initial content, delayed input response, scroll jank, or layout movement. Record a Chrome or Edge DevTools Performance trace with CPU and network throttling appropriate to the audience. Inspect the network waterfall for late discovery and blocking chains, then the main-thread flame chart for long tasks and repeated style, layout, or paint. Enable paint flashing and layer borders only when investigating those stages; diagnostic overlays themselves can add overhead.

In the trace, ask causal questions. Did JavaScript mutate thousands of nodes? Did a geometry read force layout? Is one large element repainting during scroll? Did an animation create many oversized layers? Compare before and after under the same conditions, and verify field metrics rather than celebrating a synthetic score alone.

Practical fixes follow the stage that owns the cost:

  • Send useful HTML early, compress it, and remove redirects from critical resources.
  • Keep critical CSS small; split route-specific styles and remove unused rules.
  • Defer noncritical JavaScript, reduce bundle execution cost, and break long tasks.
  • Reserve image, embed, and ad geometry; optimize and prioritize the LCP resource.
  • Let CSS perform layout; batch unavoidable DOM reads before writes.
  • Virtualize genuinely large lists and use containment for independent regions.
  • Animate transform and opacity when visually equivalent, without creating layers indiscriminately.
  • Measure LCP, INP, and CLS in production and attach diagnostics such as route, device class, and release.

Takeaways

Rendering is a dependency graph from bytes to models, geometry, drawing commands, raster tiles, and composited pixels. DOM or CSS changes invalidate different portions of that graph. Main-thread scheduling determines whether the browser can complete the work before a frame deadline. Observers and user-centered metrics reveal symptoms without constant polling, while a performance trace identifies the responsible stage. Optimize the measured bottleneck, preserve visual stability, and retest on hardware and networks that resemble those of real users.