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
- The pixel pipeline — six stages
- Critical Rendering Path and blocking resources
- The main thread and long tasks
- Layout (reflow) — triggers, thrashing, batching
- Style recalculation and containment
- Paint — invalidation, paint areas, repaints
- Compositor thread, layers, and GPU rasterization
- Engine differences — Blink, Gecko, WebKit (2024–2026)
- Practical profiling — DevTools and PerformanceObserver
- 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)
└────────────┘
| Stage | Input | Output | Typical cost driver |
|---|---|---|---|
| Parse | HTML bytes | DOM tree | Document size, <script> without defer/async |
| Style | DOM + CSSOM | Computed styles per node | Selector complexity, DOM depth, :has() fan-out |
| Render tree | DOM + styles | Visible nodes only | display: none, visibility: hidden pruning |
| Layout | Render tree + viewport | Box geometry | DOM size, flex/grid, text, fonts |
| Paint | Layout results | Display list (draw records) | Shadows, borders, gradients, large backgrounds |
| Composite | Painted layers | GPU-backed tiles | Layer 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
| Resource | Blocks HTML parser? | Blocks first render? | Why |
|---|---|---|---|
Sync <script> (no defer/async) | Yes | Indirectly (parser paused) | Parser must execute script before continuing |
CSS (<link rel="stylesheet">) | No (parser continues) | Yes | No render tree without computed styles — FOUC risk |
@import in CSS | No | Yes (cascade) | Serial fetch chain |
| Web fonts | No | Can delay text paint | Invisible text until font swap (FOUT/FOIT) |
async script | No | No (after download) | Executes when ready, may block later |
defer script | No | No | Runs 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>và<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;
}
| Symptom | Likely main-thread culprit | Compositor still smooth? |
|---|---|---|
| Scroll stutters on heavy JS | Long tasks during scroll | Sometimes (if layers OK) |
Animation jank on top/left | Layout every frame | No |
Animation smooth on transform | Composite-only | Often yes |
| Input delay after tap | Long task before event handler | N/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,visualViewportchanges - 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:
requestAnimationFramekhô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 CSSposition: stickyhoặctransformthâ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).
| Value | Effect |
|---|---|
contain: layout | Internal layout does not affect external geometry |
contain: paint | Descendants do not paint outside box; creates stacking context |
contain: style | Counters/quotes scoped (limited browser support for full isolation) |
contain: size | Element size independent of children (requires explicit dimensions) |
contain: strict | layout paint size shorthand |
contain: content | layout 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, notdisplay) - 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
outline và box-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 transform và opacity 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 / condition | Effect |
|---|---|
transform: translateZ(0) / translate3d(0,0,0) | Legacy promotion hack — still works but prefer intentional design |
will-change: transform, opacity | Upstream hint; creates layer early — remove after animation |
position: fixed / sticky | Often composited for scroll |
<video>, <canvas>, WebGL | Separate layers by default |
| Opacity animation on element | May promote for duration |
3D transform, filter, backdrop-filter | Often own layer |
overflow: scroll on large containers | Tiled 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-changeblanket 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.
8. Engine differences — Blink, Gecko, WebKit (2024–2026)
Engine dùng chung tên pipeline nhưng khác threading, layout system, và compositor backend.
| Engine | Browser(s) | Layout | Compositing / raster notes |
|---|---|---|---|
| Blink | Chrome, Edge, Opera | LayoutNG (block/flex/grid unified) | Viz compositor; GPU raster; scroll unification project |
| Gecko | Firefox | Servo-derived layout (layout engine refresh ongoing) | WebRender (GPU-centric); OMTP (off-main-thread painting) |
| WebKit | Safari | Layout integration with platform | Core 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 marker | Meaning | Action |
|---|---|---|
| Recalculate Style | Selector match + computed style | Reduce selector scope; avoid classList churn |
| Layout | Reflow | Batch reads/writes; containment |
| Update Layer Tree | Promotion/demotion | Audit will-change; reduce depth |
| Paint | Raster prep | Simplify shadows; contain: paint |
| Composite Layers | GPU assembly | Usually fine unless layer count huge |
| Long Task (red triangle) | Greater than 50 ms JS | Split 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
transformvàopacity; gỡwill-changesau khi dùng - List lớn dùng
content-visibilityhoặc virtualization — không phải 10k DOM node với selector global - Chrome fixed/sticky dùng
contain: paintkhi phù hợp - Profile trên Chrome và 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:
- Style dirty → recalc selector cho subtree
- Layout dirty → tính lại geometry
- Paint dirty → tạo lại display list cho damage rect
- 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.