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.

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

  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

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)
                                   └────────────┘
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

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

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 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><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;
}
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

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, visualViewport changes
  • 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: requestAnimationFrame does not run while the tab is backgrounded — do not rely on it for correctness logic {Gotcha: requestAnimationFrame không chạy khi tab background — đừng dựa vào nó cho logic correctness}. For scroll-linked effects, prefer CSS position: sticky or compositor-friendly transform over per-scroll layout reads {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 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)}.

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 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, 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

outline and box-shadow are particularly costly because they expand paint invalidation beyond the element box {outlinebox-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 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

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 / 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: 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-change on list items {Dùng panel Layers trong DevTools để audit; tránh will-change blanket 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}.


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}.

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 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 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()

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 transform and opacity; will-change removed after use {Animation dùng transformopacity; gỡ will-change sau khi dùng}
  • Large lists use content-visibility or virtualization — not 10k DOM nodes with global selectors {List lớn dùng content-visibility hoặc virtualization — không phải 10k DOM node với selector global}
  • Fixed/sticky chrome uses contain: paint where appropriate {Chrome fixed/sticky dùng contain: paint khi phù hợp}
  • Profiled on Chrome and Safari/Firefox — layer behavior differs {Profile trên Chrome 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}:

  1. Style dirty → recalc selectors for subtree {Style dirty → recalc selector cho subtree}
  2. Layout dirty → recompute geometry {Layout dirty → tính lại geometry}
  3. Paint dirty → regenerate display lists for damage rect {Paint dirty → tạo lại display list cho damage rect}
  4. 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}.