Browser, HTTP & networking

How browsers render

From bytes to pixels — parsing and the preload scanner, render-blocking CSS and script loading attributes, the style-layout-paint-composite pipeline, the frame budget, layout thrashing, the observer APIs, long tasks and INP, and where Web Workers fit.

Every performance conversation eventually reaches the rendering pipeline: what blocked the first paint, why the layout shifted, why a click took 400 ms to respond. Interviewers ask "what happens when the HTML arrives" to see whether you know the order of operations well enough to reason about a waterfall or a profile.

Know

  • Parsing: the HTML tokenizer builds the DOM incrementally; a preload scanner looks ahead and starts fetching scripts, stylesheets, images and fonts before the parser reaches them (which is why hiding a critical image behind CSS or JavaScript delays it). Invalid nesting is repaired by the parser, which is a source of hydration mismatches.
  • CSS is render-blocking: the browser will not paint until the CSSOM for blocking stylesheets is built (it would otherwise flash unstyled content). media attributes make stylesheets non-blocking when they do not match; inlining critical CSS is the trade-off for very large stylesheets.
  • Scripts: a classic <script> blocks parsing while it downloads and executes; defer downloads in parallel and executes in order after parsing; async executes as soon as it arrives, out of order; type="module" is deferred by default and strict. Nuxt emits module scripts with modulepreload hints.
  • The pipeline per frame: input handlers → requestAnimationFrame callbacks → style (compute styles) → layout (geometry; "reflow") → paint (rasterise into layers) → composite (GPU assembles layers). transform and opacity changes can skip layout and paint (compositor-only); width, top, font-size force layout; color, background force paint. The frame budget at 60 Hz is about 16 ms, less on 120 Hz displays.
  • Forced synchronous layout and layout thrashing: reading geometry (offsetHeight, getBoundingClientRect(), getComputedStyle()) after a write forces layout immediately; alternating reads and writes in a loop forces it repeatedly. Batch reads, then writes; use requestAnimationFrame for visual updates; ResizeObserver instead of polling sizes.
  • Long tasks and INP: a task over 50 ms delays input processing; INP measures the worst interaction's delay (input delay + processing + presentation). Fixes: break work with scheduler.yield(), defer non-urgent work (requestIdleCallback, setTimeout), avoid layout thrashing in handlers, reduce DOM size, move CPU work to Web Workers (postMessage, structuredClone, transferables; OffscreenCanvas for drawing off-thread).
  • Observers replace polling: IntersectionObserver (lazy loading, infinite scroll, visibility-based hydration, analytics), ResizeObserver (element size changes, container-like logic in JS), MutationObserver (DOM changes; runs as a microtask), PerformanceObserver (long tasks, LCP candidates, layout shifts, event timing for INP, resource timing).
  • Layout stability: dimensions on media, reserved space for late content, font fallbacks with matching metrics, no content inserted above the viewport; content-visibility: auto and contain limit layout scope for long pages.
  • Metrics vocabulary: TTFB (server), FCP (first paint of content), LCP (largest element; good under 2.5 s), INP (responsiveness; good under 200 ms), CLS (stability; good under 0.1), TBT (lab proxy for INP). Field data (CrUX, RUM via web-vitals) at the 75th percentile is what counts; Lighthouse is a lab.
  • Rendering in Nuxt terms: SSR gives early FCP/LCP because HTML arrives styled; hydration is JavaScript work that can create long tasks, which is why lazy hydration and server components matter; experimental.ssrStreaming flushes the shell earlier; NuxtLink prefetch and Speculation Rules move navigation cost ahead of the click.

How it works

Loading attributes and hints that decide the critical path:

what the server should emit
<head>
  <link rel="preconnect" href="https://cdn.example.com">
  <link rel="preload" as="image" href="/hero.avif" fetchpriority="high">     <!-- LCP image found early -->
  <link rel="stylesheet" href="/_nuxt/entry.css">                              <!-- render-blocking, small -->
  <link rel="stylesheet" href="/_nuxt/print.css" media="print">                <!-- non-blocking -->
  <link rel="modulepreload" href="/_nuxt/entry.js">
  <script type="module" src="/_nuxt/entry.js"></script>                        <!-- deferred by default -->
  <script async src="https://analytics.example.com/tag.js"></script>           <!-- independent, out of order -->
</head>

Layout thrashing and its fix, the way it appears in a real component:

app/composables/useEqualHeights.ts
export function useEqualHeights(items: Ref<HTMLElement[]>) {
  // ✗ read, write, read, write: each write invalidates layout and the next read forces it again
  // for (const el of items.value) { el.style.height = ''; el.style.height = `${Math.max(...items.value.map(i => i.offsetHeight))}px` }

  // ✓ batch: all reads, then all writes, inside a frame
  function equalise() {
    requestAnimationFrame(() => {
      for (const el of items.value) el.style.height = ''      // writes (reset)
      const max = Math.max(...items.value.map(el => el.offsetHeight)) // reads, one forced layout
      for (const el of items.value) el.style.height = `${max}px`      // writes
    })
  }

  // ResizeObserver instead of a resize listener + polling
  useResizeObserver(items, equalise)
  onMounted(equalise)
}

Measuring what the user experiences, in the browser, and moving CPU work off the main thread:

app/plugins/vitals.client.ts
import { onCLS, onINP, onLCP } from 'web-vitals/attribution'

export default defineNuxtPlugin(() => {
  const send = (m: { name: string, value: number, attribution: Record<string, unknown> }) =>
    navigator.sendBeacon('/api/telemetry/vitals', JSON.stringify({ name: m.name, value: m.value, target: m.attribution.interactionTarget ?? m.attribution.element, path: location.pathname }))
  onLCP(send)
  onINP(send) // attribution tells you *which* interaction and element was slow
  onCLS(send)
})
app/workers/search.worker.ts
// A dedicated module worker: heavy filtering off the main thread
self.onmessage = (e: MessageEvent<{ rows: Row[], query: string }>) => {
  const q = e.data.query.toLowerCase()
  const hits = e.data.rows.filter(r => r.name.toLowerCase().includes(q))
  postMessage(hits)
}
app/composables/useWorkerSearch.ts
export function useWorkerSearch(rows: Ref<Row[]>) {
  const results = shallowRef<Row[]>([])
  let worker: Worker | undefined
  onMounted(() => { worker = new Worker(new URL('~/workers/search.worker.ts', import.meta.url), { type: 'module' }); worker.onmessage = e => { results.value = e.data } })
  onScopeDispose(() => worker?.terminate())
  const search = (query: string) => worker?.postMessage({ rows: toRaw(rows.value), query }) // structured clone; toRaw avoids cloning the Proxy
  return { results, search }
}
Gotcha· `async` for the entry script

async runs as soon as the script arrives, possibly before the DOM it needs exists and out of order relative to its dependencies. Application entry points are defer or type="module"; async is for independent third-party tags.

Gotcha· Reading offsetHeight in a v-for

A template helper that calls getBoundingClientRect() for each row while the list renders forces a layout per row: 1,000 rows, 1,000 layouts, one long task. Measure once after render with a ResizeObserver or requestAnimationFrame, or avoid measuring by using CSS (grid, aspect-ratio).

Verify before the interview:

The current Core Web Vitals thresholds and whether Chrome has announced any new or changed metric for 2026; the set was LCP, INP and CLS as of this snapshot.

docs ↗

Exercise

Exercise
  • Record a Performance profile of this app's home page on a throttled CPU; identify the long tasks, which are hydration, and the LCP element.
  • Write the thrashing version of useEqualHeights with 500 cards, observe the purple layout blocks in the profile, then batch the reads and writes.
  • Move a heavy filter into a worker and compare INP in the Performance panel's interactions track before and after.

Be able to say

Be able to say· What happens between the HTML response arriving and the first pixel?

"The browser tokenises the HTML into a DOM incrementally while a preload scanner races ahead to fetch stylesheets, scripts, images and fonts it sees. Blocking stylesheets must finish before anything is painted, so their CSSOM is built; classic scripts pause the parser while defer and module scripts wait until parsing is done. With DOM and CSSOM the engine computes styles, runs layout to get geometry, paints into layers and composites them on the GPU; the first paint is the first frame that completes that pipeline and LCP is when the largest element lands. After that, every interaction goes through the same style-layout-paint-composite steps in a 16 ms frame budget, which is why a long task or a layout-thrashing handler shows up directly as a bad INP."

Be able to say· What is layout thrashing and how do you avoid it?

"Reading geometry, offsetHeight, getBoundingClientRect, getComputedStyle, after a style change forces the browser to run layout synchronously so the value is current; alternating writes and reads in a loop forces that layout again and again, which is thrashing. The fix is to batch: read everything first, then write, ideally inside requestAnimationFrame, so one layout serves all reads. ResizeObserver replaces measuring in resize handlers, and often the measurement disappears entirely when CSS can express the constraint with grid or aspect-ratio. In the Performance panel it appears as a run of purple layout blocks with 'forced reflow' warnings."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.