jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Browser Rendering Pipeline — from HTML bytes to composited pixels

Parse, layout, paint, composite: critical path, main-thread contention, reflow thrashing, layer promotion, and engine changes in Blink, Gecko, and WebKit.

Mỗi frame, trình duyệt biến bytes thành pixel qua một pipeline vốn vô hình cho đến khi nó hỏng. Kỹ sư senior tối ưu bundle và cache, nhưng vẫn ship scroll giật và layout thrashing vì chưa xác định work xảy ra ở đâu — parser, style, layout, paint, hay compositor. Bài viết này là bản đồ đó: toàn bộ đường đi từ network bytes đến GPU texture, failure mode ở từng giai đoạn, và mental model principal engineer dùng khi profiling.


Table of contents

  1. The pixel pipeline — six stages
  2. Critical Rendering Path and blocking resources
  3. The main thread and long tasks
  4. Layout (reflow) — triggers, thrashing, batching
  5. Style recalculation and containment
  6. Paint — invalidation, paint areas, repaints
  7. Compositor thread, layers, and GPU rasterization
  8. Engine differences — Blink, Gecko, WebKit (2024–2026)
  9. Practical profiling — DevTools and PerformanceObserver
  10. Principal engineer checklist and mental model

1. The pixel pipeline — six stages

Trình duyệt hiện đại không “vẽ trang” một lần duy nhất. Chúng duy trì các cây trung gian và chạy lại tập con của pipeline mỗi khi có thay đổi. Hiểu giai đoạn nào chạy lại với một thay đổi DOM/CSS cụ thể là kỹ năng cốt lõi.

  Network bytes


  ┌─────────┐     ┌──────────┐
  │  Parse  │ ──► │   DOM    │
  └─────────┘     └────┬─────┘

  ┌─────────┐     ┌────▼─────┐     ┌─────────────┐
  │  Parse  │ ──► │  CSSOM   │ ──► │ Render tree │  (visible nodes + computed styles)
  └─────────┘     └──────────┘     └──────┬──────┘


                                   ┌────────────┐
                                   │   Layout   │  (geometry: positions, sizes)
                                   └─────┬──────┘


                                   ┌────────────┐
                                   │   Paint    │  (display lists, draw ops)
                                   └─────┬──────┘


                                   ┌────────────┐
                                   │ Composite  │  (layers → GPU textures → screen)
                                   └────────────┘
StageInputOutputTypical cost driver
ParseHTML bytesDOM treeDocument size, <script> without defer/async
StyleDOM + CSSOMComputed styles per nodeSelector complexity, DOM depth, :has() fan-out
Render treeDOM + stylesVisible nodes onlydisplay: none, visibility: hidden pruning
LayoutRender tree + viewportBox geometryDOM size, flex/grid, text, fonts
PaintLayout resultsDisplay list (draw records)Shadows, borders, gradients, large backgrounds
CompositePainted layersGPU-backed tilesLayer count, overdraw, filter chains

Bẫy thuật ngữ: “reflow” và “layout” là cùng một giai đoạn; “repaint” là paint không layout; “composite-only” bỏ qua layout và paint trên layer được promote.

Pipeline là incremental. Khi bạn đổi element.style.color, engine đánh dấu node dirty và có thể bỏ qua layout nếu geometry không đổi. Khi bạn đổi element.style.width, layout chạy cho subtree đó (và đôi khi ancestor/sibling tùy formatting context).


2. Critical Rendering Path and blocking resources

Critical Rendering Path (CRP) là lượng work tối thiểu để render first meaningful paint. Không phải “load hết” — mà là “cái gì block parser và first paint”.

Parser-blocking vs render-blocking

ResourceBlocks HTML parser?Blocks first render?Why
Sync <script> (no defer/async)YesIndirectly (parser paused)Parser must execute script before continuing
CSS (<link rel="stylesheet">)No (parser continues)YesNo render tree without computed styles — FOUC risk
@import in CSSNoYes (cascade)Serial fetch chain
Web fontsNoCan delay text paintInvisible text until font swap (FOUT/FOIT)
async scriptNoNo (after download)Executes when ready, may block later
defer scriptNoNoRuns after parse, before DOMContentLoaded

CSS render-blocking theo thiết kế. Trình duyệt giữ paint thay vì flash unstyled content, nên <link rel="stylesheet"> trong <head> trì FCP nhưng cải thiện chất lượng cảm nhận.

JavaScript đồng bộ parser-blocking. Khi parser tạm dừng, không có DOM node mới, trì CSSOM và mọi thứ phía sau.

<!-- ❌ Parser stops at each sync script -->
<script src="/analytics.js"></script>
<script src="/feature-flags.js"></script>

<!-- ✅ Parse completes, scripts run in order before DOMContentLoaded -->
<script defer src="/analytics.js"></script>
<script defer src="/feature-flags.js"></script>

<!-- ✅ Independent scripts, order not guaranteed -->
<script async src="/ads.js"></script>

Preload, preconnect, and fetch priority

rel="preconnect" mở TCP/TLS sớm tới third-party origin. rel="preload" fetch resource kèm hint khi nào cần. Preload sai cạnh tranh với resource thực sự critical trên cùng connection.

<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<link
  rel="preload"
  href="/fonts/inter-var.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>
<link rel="preload" href="/css/critical.css" as="style" />

Ghi chú 2024–2026: Chromium hỗ trợ fetchpriority="high" trên <img><link> để bias network scheduler mà không lạm dụng preload cho mọi asset. Speculation Rules prefetch/prerender navigation ở phía compositor trên browser hỗ trợ — trực giao với CRP nhưng ảnh hưởng paint tiếp theo.

Font trì text paint, không phải parser — dùng font-display: swap hoặc optional, và metric override trong @font-face để giới hạn CLS.


3. The main thread and long tasks

JavaScript, style, layout, paint (một phần), và event delivery dùng chung main thread trên mọi engine lớn. Compositor thread scroll và composite độc lập chỉ khi main thread không bóp nó bằng work.

  Main thread (per tab / site-isolated renderer)
  ┌──────────────────────────────────────────────────────┐
  │ Input → JS → Style → Layout → Paint (partial) → ...  │
  └──────────────────────────────────────────────────────┘

                              │ layer updates, scroll offsets

  Compositor thread
  ┌──────────────────────────────────────────────────────┐
  │ Scroll, animation (transform/opacity), composite     │
  └──────────────────────────────────────────────────────┘

Long task là mọi đoạn main thread liên tục trên ~50 ms (INP và RAIL dùng ngưỡng này). Một long task vượt budget frame 60 fps (16.7 ms) nhiều lần.

// ❌ 200ms sync work — blocks input and defers layout
rows.map(expensiveTransform);

// ✅ Yield between chunks (scheduler.yield in Chromium 129+)
async function processInChunks(rows, chunkSize = 500) {
  const out = [];
  for (let i = 0; i < rows.length; i += chunkSize) {
    out.push(...rows.slice(i, i + chunkSize).map(expensiveTransform));
    if ('scheduler' in globalThis && 'yield' in scheduler) await scheduler.yield();
  }
  return out;
}
SymptomLikely main-thread culpritCompositor still smooth?
Scroll stutters on heavy JSLong tasks during scrollSometimes (if layers OK)
Animation jank on top/leftLayout every frameNo
Animation smooth on transformComposite-onlyOften yes
Input delay after tapLong task before event handlerN/A

Insight principal: Compositor nhanh; main thread là nút thắt cho mọi thứ chạm DOM, style, hoặc layout.


4. Layout (reflow) — triggers, thrashing, batching

Layout tính geometry: width, height, line break, phân bổ flex, grid track. Nó O(n) hoặc tệ hơn trên subtree — không phải O(1) mỗi lần đổi property.

What triggers layout

Layout invalidate khi input ảnh hưởng geometry đổi:

  • Element box model: width, height, padding, border, margin, box-sizing
  • Positioning: top, left, right, bottom, position, float, clear
  • Flex/grid: flex-*, grid-*, gap, align-*, justify-*
  • Typography: font-size, font-family, line-height, letter-spacing, text content changes
  • Viewport: window.resize, mobile URL bar show/hide, visualViewport changes
  • Reading layout: offsetWidth, getBoundingClientRect(), scrollTop (when dirty)

Đổi chỉ opacity, transform, hoặc filter (trên layer promoted) thường bỏ qua layout. Đổi color hoặc background-color kích paint, không layout (trừ edge case ảnh hưởng intrinsic sizing).

Layout thrashing and forced synchronous layout

Layout thrashing (read/write/read/write trong vòng lặp) buộc engine flush layout đang chờ giữa mỗi lần read.

// ❌ Forced synchronous layout — N layouts for N elements
const boxes = [];
for (const el of elements) {
  el.style.width = `${container.offsetWidth / elements.length}px`; // read then write
  boxes.push(el.getBoundingClientRect()); // forces layout flush
}

// ✅ Batch reads, then batch writes
const containerWidth = container.offsetWidth;
const slice = containerWidth / elements.length;
const boxes = elements.map((el) => el.getBoundingClientRect()); // all reads first
elements.forEach((el, i) => {
  el.style.width = `${slice}px`;
  // boxes[i] may be stale if you needed post-write geometry — re-read once after writes
});

Trình duyệt giữ dirty layout flag. Write đánh dấu dirty; read buộc layout flush nếu dirty. Đó là lý do xen kẽ DOM read và write trên hot path rất tệ.

requestAnimationFrame batching

requestAnimationFrame chạy trước paint tiếp theo, gom visual update với refresh màn hình. Dùng nó để gom DOM write ảnh hưởng layout một lần mỗi frame.

let pendingWidth = null;

function scheduleWidthUpdate(nextWidth) {
  pendingWidth = nextWidth;
  if (scheduled) return;
  scheduled = true;
  requestAnimationFrame(() => {
    scheduled = false;
    panel.style.width = `${pendingWidth}px`;
    pendingWidth = null;
  });
}

Gotcha: requestAnimationFrame không chạy khi tab background — đừng dựa vào nó cho logic correctness. Với hiệu ứng gắn scroll, ưu tiên CSS position: sticky hoặc transform thân compositor thay vì layout read mỗi scroll.


5. Style recalculation and containment

Style recalc khớp selector và resolve computed value cho node dirty. Chạy trước layout khi class, attribute, hoặc inline style đổi.

Selector cost and :has()

Selector sâu (div > ul > li > a.active) và rule universal (*) mở rộng matching work. :has() mạnh nhưng có thể buộc engine xét subtree trước đây prune được — profile trước khi ship chuỗi :has() phức tạp trên list lớn.

/* ❌ Every DOM mutation may re-evaluate broad :has() on large trees */
.card:has(.badge[data-urgent='true']) {
  border-color: var(--accent);
}

/* ✅ Scope with a class toggled by JS when urgency changes */
.card--urgent {
  border-color: var(--accent);
}

CSS containment

contain báo engine rằng thay đổi bên trong element không ảnh hưởng layout/paint bên ngoài (trong các trục khai báo).

ValueEffect
contain: layoutInternal layout does not affect external geometry
contain: paintDescendants do not paint outside box; creates stacking context
contain: styleCounters/quotes scoped (limited browser support for full isolation)
contain: sizeElement size independent of children (requires explicit dimensions)
contain: strictlayout paint size shorthand
contain: contentlayout paint shorthand
.list-item {
  contain: content; /* isolate layout + paint for virtualized rows */
}

content-visibility

content-visibility: auto bỏ qua rendering work cho subtree off-screen nhưng giữ accessibility và indexing (kèm contain-intrinsic-size).

.feed-section {
  content-visibility: auto;
  contain-intrinsic-size: auto 500px; /* placeholder height until measured */
}

Trade-off: Lần hiện đầu của subtree bị skip trả spike layout + paint — ổn cho feed section, rủi ro cho hero above-the-fold.


6. Paint — invalidation, paint areas, repaints

Paint ghi item display list: fill rect, glyph text, image, border, shadow. Paint thường đắt hơn composite nhưng rẻ hơn layout cả document.

What causes repaints

Repaint kích hoạt gồm (không đủ):

  • Color and background changes
  • Visibility (visibility, not display)
  • Outline, box-shadow, text-shadow
  • Border-radius changes (may also affect layout in some cases)
  • Canvas 2D draw calls
  • SVG attribute animations on non-composited properties

outlinebox-shadow đặc biệt tốn vì mở rộng paint invalidation ra ngoài box element.

Paint areas and invalidation propagation

Engine tính damage rect (vùng invalidate) và repaint layer giao nhau. Header position: fixed có drop shadow có thể invalidate phần lớn viewport mỗi scroll nếu không isolate.

/* ✅ Promote fixed chrome to its own layer — scroll repaints less underneath */
.app-header {
  position: fixed;
  will-change: transform; /* dev-only hint; remove after promotion if possible */
  contain: paint;
}

DevTools Rendering → Paint flashing phủ xanh vùng repaint — không thể thiếu để bắt flash full-screen bất ngờ.


7. Compositor thread, layers, and GPU rasterization

Compositor ghép layer (bitmap tile hoặc GPU texture) và áp transform, opacity, scroll offset trên compositor thread. Đó là lý do animation transformopacity có thể 60+ fps khi main thread bận — nếu property đó không kích layout/paint trên subtree mỗi frame.

Layer promotion heuristics

Trình duyệt promote element lên compositor layer khi lợi lớn hơn chi phí memory:

Hint / conditionEffect
transform: translateZ(0) / translate3d(0,0,0)Legacy promotion hack — still works but prefer intentional design
will-change: transform, opacityUpstream hint; creates layer early — remove after animation
position: fixed / stickyOften composited for scroll
<video>, <canvas>, WebGLSeparate layers by default
Opacity animation on elementMay promote for duration
3D transform, filter, backdrop-filterOften own layer
overflow: scroll on large containersTiled compositor layers for scrollport
.slide-panel {
  transform: translateX(100%);
  transition: transform 240ms ease;
  will-change: transform; /* add before transition, remove on transitionend */
}

.slide-panel.is-open {
  transform: translateX(0);
}
panel.addEventListener('transitionend', (e) => {
  if (e.propertyName === 'transform') {
    panel.style.willChange = 'auto';
  }
});

Failure mode — layer explosion: Quá nhiều layer promoted tăng GPU memory và hại mobile — mỗi layer là một dòng budget texture. Dùng panel Layers trong DevTools để audit; tránh will-change blanket trên list item.

Why transform/opacity are “cheap”

Animate left đổi layout mỗi frame → style → layout → paint → composite. Animate transform: translateX() trên compositor layer chỉ cập nhật matrix transform compositor — main thread có thể không chạy layout/paint.

/* ❌ Layout every frame */
@keyframes slide-bad {
  from { left: 0; }
  to { left: 200px; }
}

/* ✅ Composite-friendly */
@keyframes slide-good {
  from { transform: translateX(0); }
  to { transform: translateX(200px); }
}

Rasterization: Output paint được chia tile (thường 256×256 px trong Chromium) rồi upload GPU. Scroll chủ yếu dịch tile có sẵn; vùng lộ mới rasterize bất đồng bộ trên worker thread trên engine hiện đại.


Engine dùng chung tên pipeline nhưng khác threading, layout system, và compositor backend.

EngineBrowser(s)LayoutCompositing / raster notes
BlinkChrome, Edge, OperaLayoutNG (block/flex/grid unified)Viz compositor; GPU raster; scroll unification project
GeckoFirefoxServo-derived layout (layout engine refresh ongoing)WebRender (GPU-centric); OMTP (off-main-thread painting)
WebKitSafariLayout integration with platformCore Animation integration on Apple platforms

Blink / LayoutNG thống nhất layout block, flex, grid với invalidation rõ hơn. Gecko / WebRender đẩy paint và raster về GPU worker. WebKit dựa Core Animation cho scroll trên nền tảng Apple. Đừng giả định layer promotion giống nhau — test Safari iOS và Firefox, không chỉ Chrome.

Off-main-thread compositing and scrolling

Scroll compositor-driven cập nhật scroll offset trên compositor thread; JS main thread không block áp scroll position (dù hit-testing và pointer-events vẫn phối hợp giữa thread). Passive event listener (\{ passive: true \}) cho compositor scroll ngay không chờ JS preventDefault().

document.addEventListener(
  'touchstart',
  onTouchStart,
  { passive: true }, // scroll not blocked waiting for this handler
);

Nếu handler phải gọi preventDefault() cho swipe tùy chỉnh, dùng \{ passive: false \} có chủ đích — bạn bỏ lợi ích scroll compositing cho listener đó.


9. Practical profiling — DevTools and PerformanceObserver

Chrome DevTools Performance panel

Ghi trace khi reproduce jank. Tìm block vàng Layout, tím Recalculate Style, xanh Paint, nâu Composite Layers xếp trên Scripting vàng dài.

Trace markerMeaningAction
Recalculate StyleSelector match + computed styleReduce selector scope; avoid classList churn
LayoutReflowBatch reads/writes; containment
Update Layer TreePromotion/demotionAudit will-change; reduce depth
PaintRaster prepSimplify shadows; contain: paint
Composite LayersGPU assemblyUsually fine unless layer count huge
Long Task (red triangle)Greater than 50 ms JSSplit work; scheduler.yield()

Bật Screenshots và track Web Vitals trong trace để correlate spike INP với giai đoạn pipeline.

PerformanceObserver for layout and long tasks

PerformanceObserver đưa long task và (nơi hỗ trợ) layout-shift, event timing lên production.

const longTaskObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // entry.duration > 50ms — correlate with attribution in Chromium 115+
    console.warn('longtask', entry.duration, entry.name, entry.startTime);
  }
});
longTaskObserver.observe({ type: 'longtask', buffered: true });

const layoutShiftObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!(entry.hadRecentInput)) {
      console.warn('cls', entry.value, entry.sources);
    }
  }
});
layoutShiftObserver.observe({ type: 'layout-shift', buffered: true });

Chromium cũng hỗ trợ elementtiming cho timestamp render từng element — sample trên production, đừng log mọi entry.


10. Principal engineer checklist and mental model

Trước khi dùng will-change khắp nơi, đi cây quyết định này:

  User-visible jank?

       ├─ No → measure first (field + lab)

       └─ Yes → Is scroll/input delayed?

                 ├─ Yes → Long tasks? INP? Split JS, passive listeners

                 └─ No → Is animation janky?

                           ├─ Uses top/left/width/height? → Move to transform/opacity

                           ├─ Full-screen repaint flash? → contain, simplify shadows

                           └─ Layer count greater than 50? → Demote unnecessary promotion

Checklist

  • CSS critical inline hoặc preload; script sync loại bỏ hoặc defer
  • Không xen kẽ layout read/write trong vòng lặp hoặc callback ResizeObserver
  • Animation dùng transformopacity; gỡ will-change sau khi dùng
  • List lớn dùng content-visibility hoặc virtualization — không phải 10k DOM node với selector global
  • Chrome fixed/sticky dùng contain: paint khi phù hợp
  • Profile trên Chrome Safari/Firefox — hành vi layer khác nhau
  • Metric field: LCP (load path), INP (main thread + input), CLS (layout + font) — mỗi cái map giai đoạn pipeline khác

Closing mental model

Nghĩ theo phạm vi invalidation:

  1. Style dirty → recalc selector cho subtree
  2. Layout dirty → tính lại geometry
  3. Paint dirty → tạo lại display list cho damage rect
  4. Compositor-only → cập nhật transform/opacity/scroll trên GPU

Mọi thay đổi DOM hoặc CSS trả ít nhất một chi phí invalidation — principal engineer giữ hot path ở cập nhật compositor-only và không ship layout trong scroll handler mà không đo.