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.
Every frame the browser turns bytes into pixels through a pipeline that is mostly invisible until it breaks {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}. Senior engineers optimize bundles and caches, yet still ship janky scroll and layout thrashing because they never mapped where work happens — parser, style, layout, paint, or compositor {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}. This post is that map: the full path from network bytes to GPU textures, the failure modes at each stage, and the mental model principal engineers use when profiling {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
Modern browsers do not “draw the page” once {Trình duyệt hiện đại không “vẽ trang” một lần duy nhất}. They maintain intermediate trees and re-run subsets of the pipeline on every change {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}. Understanding which stages rerun for a given DOM or CSS change is the core skill {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 |
Terminology trap: “reflow” and “layout” are the same stage; “repaint” is paint without layout; “composite-only” updates skip layout and paint on promoted layers {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}.
The pipeline is incremental {Pipeline là incremental}.
When you change element.style.color, the engine marks dirty nodes and may skip layout entirely if geometry is unchanged {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}.
When you change element.style.width, layout runs for that subtree (and sometimes ancestors/siblings depending on formatting context) {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
The Critical Rendering Path (CRP) is the minimum work to render first meaningful paint {Critical Rendering Path (CRP) là lượng work tối thiểu để render first meaningful paint}. It is not “load everything” — it is “what blocks the parser and first 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 is render-blocking by design {CSS render-blocking theo thiết kế}.
The browser will hold paint rather than flash unstyled content, which is why <link rel="stylesheet"> in <head> delays First Contentful Paint (FCP) but improves perceived quality {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}.
Sync JavaScript is parser-blocking {JavaScript đồng bộ parser-blocking}. While the parser is paused, no new DOM nodes arrive, which delays CSSOM attachment and everything downstream {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" opens TCP/TLS early to a third-party origin {rel="preconnect" mở TCP/TLS sớm tới third-party origin}.
rel="preload" fetches a resource with a hint about when it is needed {rel="preload" fetch resource kèm hint khi nào cần}.
Misused preload competes with truly critical resources on the same connection {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" />
2024–2026 note: Chromium supports
fetchpriority="high"on<img>and<link>to bias the network scheduler without abusing preload for every asset {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 (<script type="speculationrules">) prefetch/prerender navigations on the compositor side in supporting browsers — orthogonal to CRP but affects next paint {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}.
Fonts delay text paint, not parser completion — use font-display: swap or optional, and metric overrides in @font-face to limit CLS {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 (in part), and event delivery share the main thread in all major engines {JavaScript, style, layout, paint (một phần), và event delivery dùng chung main thread trên mọi engine lớn}. The compositor thread scrolls and composites independently only when the main thread is not starving it with work {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 │
└──────────────────────────────────────────────────────┘
A long task is any continuous main-thread slice above ~50 ms (INP and RAIL use this threshold) {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)}. One long task drops a 60 fps frame budget (16.7 ms) many times over {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 |
Principal insight: The compositor is fast; the main thread is the bottleneck for everything that touches DOM, style, or layout {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 computes geometry: widths, heights, line breaks, flex distribution, grid tracks {Layout tính geometry: width, height, line break, phân bổ flex, grid track}. It is O(n) or worse on subtrees — not O(1) per property change {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 invalidates when geometry-affecting inputs change {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)
Changing only opacity, transform, or filter (on a promoted layer) typically skips layout {Đổi chỉ opacity, transform, hoặc filter (trên layer promoted) thường bỏ qua layout}.
Changing color or background-color triggers paint, not layout (unless it affects intrinsic sizing in edge cases) {Đổ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 in a loop) forces the engine to flush pending layout between each read {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
});
The browser maintains a dirty layout flag {Trình duyệt giữ dirty layout flag}. Writes mark dirty; reads force layout flush if dirty {Write đánh dấu dirty; read buộc layout flush nếu dirty}. This is why interleaving DOM reads and writes in hot paths is catastrophic {Đó là lý do xen kẽ DOM read và write trên hot path rất tệ}.
requestAnimationFrame batching
requestAnimationFrame runs before the next paint, coalescing visual updates with the display refresh {requestAnimationFrame chạy trước paint tiếp theo, gom visual update với refresh màn hình}.
Use it to batch DOM writes that affect layout once per frame {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:
requestAnimationFramedoes not run while the tab is backgrounded — do not rely on it for correctness logic {Gotcha:requestAnimationFramekhông chạy khi tab background — đừng dựa vào nó cho logic correctness}. For scroll-linked effects, prefer CSSposition: stickyor compositor-friendlytransformover per-scroll layout reads {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 matches selectors and resolves computed values for dirty nodes {Style recalc khớp selector và resolve computed value cho node dirty}. It runs before layout when classes, attributes, or inline styles change {Chạy trước layout khi class, attribute, hoặc inline style đổi}.
Selector cost and :has()
Deep selectors (div > ul > li > a.active) and universal rules (* \{ box-sizing: border-box \}) widen matching work {Selector sâu (div > ul > li > a.active) và rule universal (*) mở rộng matching work}.
:has() is powerful but can force the engine to consider subtrees that were previously prunable — profile before shipping complex :has() chains in large lists {: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 tells the engine that an element’s internal changes do not affect outside layout/paint (within declared axes) {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 skips rendering work for off-screen subtrees while preserving accessibility and search indexing (with content-visibility: auto + contain-intrinsic-size) {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: First reveal of a skipped subtree pays a layout + paint spike — acceptable for feed sections, risky for above-the-fold hero {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 records display list items: fill rects, text glyphs, images, borders, shadows {Paint ghi item display list: fill rect, glyph text, image, border, shadow}. Paint is often more expensive than composite but cheaper than full-document layout {Paint thường đắt hơn composite nhưng rẻ hơn layout cả document}.
What causes repaints
Repaint triggers include (non-exhaustive) {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 and box-shadow are particularly costly because they expand paint invalidation beyond the element box {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
The engine computes a damage rect (invalidation region) and repaints intersecting layers {Engine tính damage rect (vùng invalidate) và repaint layer giao nhau}.
A fixed position: fixed header with a drop shadow can invalidate a large fraction of the viewport each scroll if not isolated {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 overlays green on repainted regions — indispensable for spotting unexpected full-screen flashes {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
The compositor assembles layers (bitmap tiles or GPU textures) and applies transforms, opacity, and scroll offsets on the compositor thread {Compositor ghép layer (bitmap tile hoặc GPU texture) và áp transform, opacity, scroll offset trên compositor thread}.
This is why transform and opacity animations can hit 60+ fps even when the main thread is busy — if those properties do not trigger layout/paint on the animated subtree each frame {Đó 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
Browsers promote elements to compositor layers when benefits outweigh memory cost {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: Too many promoted layers increase GPU memory and hurt mobile — each layer is a texture budget line item {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}. Use Layers panel in DevTools to audit; avoid blanket
will-changeon list items {Dùng panel Layers trong DevTools để audit; tránhwill-changeblanket trên list item}.
Why transform/opacity are “cheap”
Animating left mutates layout every frame → style → layout → paint → composite {Animate left đổi layout mỗi frame → style → layout → paint → composite}.
Animating transform: translateX() on a composited layer updates only the compositor transform matrix — main thread may not run layout/paint at all {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: Paint output is tiled (often 256×256 px in Chromium) and uploaded to GPU {Rasterization: Output paint được chia tile (thường 256×256 px trong Chromium) rồi upload GPU}. Scrolling mostly translates existing tiles; newly exposed regions rasterize asynchronously on worker threads in modern engines {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)
Engines share the same pipeline names but differ in threading, layout systems, and compositor backends {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 unified block, flex, and grid layout with clearer invalidation {Blink / LayoutNG thống nhất layout block, flex, grid với invalidation rõ hơn}. Gecko / WebRender pushes paint and raster toward GPU workers {Gecko / WebRender đẩy paint và raster về GPU worker}. WebKit leans on Core Animation for scroll on Apple platforms {WebKit dựa Core Animation cho scroll trên nền tảng Apple}. Never assume identical layer promotion — test Safari iOS and Firefox, not only Chrome {Đừng giả định layer promotion giống nhau — test Safari iOS và Firefox, không chỉ Chrome}.
Off-main-thread compositing and scrolling
Compositor-driven scrolling updates scroll offset on the compositor thread; main-thread JS cannot block scroll position application (though hit-testing and pointer-events still coordinate across threads) {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 listeners (\{ passive: true \}) allow the compositor to scroll immediately without waiting for JS preventDefault() {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
);
If your handler must call preventDefault() for custom swipe logic, use \{ passive: false \} knowingly — you forfeit scroll compositing benefits for that listener {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
Record a trace while reproducing jank {Ghi trace khi reproduce jank}. Look for yellow Layout, purple Recalculate Style, green Paint, and brown Composite Layers blocks stacked above long yellow Scripting {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() |
Enable Screenshots and Web Vitals track in the trace to correlate INP spikes with pipeline stages {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 surfaces long tasks and (where supported) layout-shift and event timing entries in production {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 also supports elementtiming for per-element render timestamps — sample in production, do not log every entry {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
Before reaching for will-change on everything, walk this decision tree {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
- Critical CSS inlined or preloaded; sync scripts eliminated or deferred {CSS critical inline hoặc preload; script sync loại bỏ hoặc defer}
- No layout read/write interleaving in loops or ResizeObserver callbacks {Không xen kẽ layout read/write trong vòng lặp hoặc callback ResizeObserver}
- Animations use
transformandopacity;will-changeremoved after use {Animation dùngtransformvàopacity; gỡwill-changesau khi dùng} - Large lists use
content-visibilityor virtualization — not 10k DOM nodes with global selectors {List lớn dùngcontent-visibilityhoặc virtualization — không phải 10k DOM node với selector global} - Fixed/sticky chrome uses
contain: paintwhere appropriate {Chrome fixed/sticky dùngcontain: paintkhi phù hợp} - Profiled on Chrome and Safari/Firefox — layer behavior differs {Profile trên Chrome và Safari/Firefox — hành vi layer khác nhau}
- Field metrics: LCP (load path), INP (main thread + input), CLS (layout + fonts) — each maps to different pipeline stages {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
Think in invalidation scopes {Nghĩ theo phạm vi invalidation}:
- Style dirty → recalc selectors for subtree {Style dirty → recalc selector cho subtree}
- Layout dirty → recompute geometry {Layout dirty → tính lại geometry}
- Paint dirty → regenerate display lists for damage rect {Paint dirty → tạo lại display list cho damage rect}
- Compositor-only → update transforms/opacities/scroll on GPU {Compositor-only → cập nhật transform/opacity/scroll trên GPU}
Every DOM or CSS change pays at least one invalidation cost — principal engineers keep hot paths in compositor-only updates and never ship layout inside scroll handlers without measurement {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}.