Bạn bao giờ tự hỏi tại sao trang web của mình load chậm, dù server response nhanh và bandwidth đủ lớn? Vấn đề có thể nằm ở Critical Rendering Path - chuỗi các bước trình duyệt phải thực hiện để chuyển HTML, CSS, JavaScript thành pixels trên màn hình.
Hiểu rõ Critical Rendering Path giúp bạn xác định chính xác bottleneck và tối ưu đúng chỗ. Thay vì đoán mò, bạn sẽ biết tại sao CSS chặn render, tại sao JavaScript làm trang trắng xóa, và cách giải quyết từng vấn đề cụ thể.
1. Critical Rendering Path Là Gì?
Critical Rendering Path (CRP) là chuỗi các bước trình duyệt thực hiện từ khi nhận HTML cho đến khi hiển thị nội dung lên màn hình.
1.1. Tổng quan 5 bước
View Mermaid diagram code
flowchart LR
HTML[HTML] --> DOM[1. DOM Tree]
CSS[CSS] --> CSSOM[2. CSSOM Tree]
DOM --> RenderTree[3. Render Tree]
CSSOM --> RenderTree
RenderTree --> Layout[4. Layout]
Layout --> Paint[5. Paint]
Paint --> Screen[Pixels trên màn hình]Giải thích 5 bước:
- DOM Construction: Parse HTML thành DOM (Document Object Model) tree
- CSSOM Construction: Parse CSS thành CSSOM (CSS Object Model) tree
- Render Tree: Kết hợp DOM và CSSOM tạo Render Tree (chỉ chứa nội dung hiển thị)
- Layout: Tính toán vị trí và kích thước của mỗi element
- Paint: Vẽ pixels lên màn hình
1.2. Tại sao gọi là “Critical”?
Tất cả 5 bước này phải hoàn thành trước khi người dùng thấy nội dung đầu tiên (First Contentful Paint). Bất kỳ thứ gì làm chậm các bước này đều trực tiếp làm chậm website.
1
2
3
4
5
6
7
8
// Đo thời gian từ khi bắt đầu load đến khi render xong
window.addEventListener("load", () => {
const [navigation] = performance.getEntriesByType("navigation");
console.log("DOM ready:", navigation.domContentLoadedEventEnd, "ms");
console.log("Page fully loaded:", navigation.loadEventEnd, "ms");
console.log("DOM Interactive:", navigation.domInteractive, "ms");
});Giải thích:
domContentLoadedEventEnd: Thời điểm DOM parse xong và các deferred scripts đã chạy (CSS chỉ làm chậm gián tiếp khi có blocking scripts)loadEventEnd: Thời điểm tất cả resources (images, fonts) đã load xongdomInteractive: Thời điểm DOM parse xong, trước khi chạy deferred scripts- Critical Rendering Path ảnh hưởng trực tiếp đến các metrics này
2. Bước 1: Xây Dựng DOM Tree
Trình duyệt đọc HTML từ trên xuống và chuyển thành DOM tree.
2.1. Quá trình parse HTML
1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html>
<head>
<title>My Website</title>
</head>
<body>
<h1>Hello World</h1>
<p>This is a paragraph</p>
</body>
</html>DOM Tree được tạo ra:
View Mermaid diagram code
flowchart TD
Document[Document] --> HTML[html]
HTML --> Head[head]
HTML --> Body[body]
Head --> Title[title]
Title --> TitleText["'My Website'"]
Body --> H1[h1]
Body --> P[p]
H1 --> H1Text["'Hello World'"]
P --> PText["'This is a paragraph'"]Giải thích:
- Mỗi HTML tag trở thành một node trong DOM tree
- Text content cũng là node
- Parse là incremental - trình duyệt không đợi tải hết HTML mới bắt đầu
2.2. Script blocking DOM construction
Khi gặp thẻ <script>, trình duyệt phải dừng parse HTML:
1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<head>
<title>My Website</title>
<script src="app.js"></script>
<!-- Parse HTML dừng tại đây -->
</head>
<body>
<h1>Hello World</h1>
<!-- Chưa được parse -->
</body>
</html>Timeline:
View Mermaid diagram code
sequenceDiagram
participant Browser
participant HTML
participant Script
Browser->>HTML: Parse HTML
HTML->>Script: Gặp script tag
Note over Browser: Dừng parse HTML
Browser->>Script: Tải và chạy app.js
Note over Browser: HTML bị chặn
Script-->>Browser: Script chạy xong
Browser->>HTML: Tiếp tục parse
HTML-->>Browser: DOM tree hoàn thànhGiải thích:
Đây chính là lý do tại sao script làm chậm rendering. Trong thực tế, có thể khắc phục bằng thuộc tính async hoặc defer (sẽ đề cập ở phần tối ưu).
2.3. Inline scripts và CSSOM dependency
Inline scripts có behavior đặc biệt - chúng phải đợi CSSOM hoàn thành nếu có CSS phía trên:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="styles.css" />
<!-- CSS blocking -->
<script>
// Script này phải đợi styles.css parse xong!
const color = getComputedStyle(document.body).color;
console.log(color);
</script>
</head>
<body>
<h1>Hello World</h1>
</body>
</html>Timeline:
View Mermaid diagram code
sequenceDiagram
participant Browser
participant HTML
participant CSS
participant Script
Browser->>HTML: Parse HTML
HTML->>CSS: Gặp stylesheet
Browser->>CSS: Download CSS
HTML->>Script: Gặp inline script
Note over Browser: Dừng parse HTML
Note over Browser: Đợi CSS download + parse
CSS-->>Browser: CSSOM ready
Browser->>Script: Execute inline script
Script-->>Browser: Script xong
Browser->>HTML: Tiếp tục parseGiải thích:
- Inline scripts không thể dùng
async/defer- luôn blocking - Browser giả định script có thể query styles (như
getComputedStyle) - Do đó phải đợi tất cả CSS phía trên parse xong mới chạy script
- Đây là “hidden cost” của inline scripts mà nhiều dev không biết
Best practice: Nếu inline script không cần đọc computed styles, đặt nó TRƯỚC thẻ <link> CSS (không có CSS pending phía trên nên script chạy ngay). Ngoài ra, có thể chuyển script xuống cuối <body> (DOM parse xong trước khi phải đợi CSS) hoặc chuyển sang external với defer.
3. Bước 2: Xây Dựng CSSOM Tree
Tương tự DOM, CSS được parse thành CSSOM tree.
3.1. Browser phát hiện CSS khi parse HTML
CSSOM không tự nhiên xuất hiện - nó bắt đầu được xây khi trình duyệt đọc HTML và gặp <link rel="stylesheet"> hoặc <style>:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="styles.css" />
<!-- Tải song song, không chặn parse HTML -->
<style>
h1 { color: blue; } <!-- Parse ngay tại chỗ -->
</style>
</head>
<body>
<h1>Hello World</h1>
<!-- HTML vẫn tiếp tục parse -->
</body>
</html>Timeline:
View Mermaid diagram code
sequenceDiagram
participant Browser
participant HTML
participant CSS
Browser->>HTML: Parse HTML
HTML->>CSS: Gặp <link rel="stylesheet">
Browser->>CSS: Download styles.css (song song)
Note over Browser,HTML: HTML tiếp tục parse
HTML->>CSS: Gặp <style> inline
Note over CSS: Parse ngay, không cần download
HTML-->>Browser: DOM tree hoàn thành
CSS-->>Browser: CSSOM tree hoàn thànhGiải thích:
<link>: Trình duyệt tải file CSS song song, không chặn việc parse HTML (khác hẳn<script>ở bước 1)<style>: Nội dung inline được parse ngay tại chỗ, không cần download- DOM và CSSOM được xây song song, mỗi cái từ nguồn dữ liệu riêng
- Điểm mấu chốt: CSS không chặn parse HTML, nhưng chặn render (xem 3.3)
3.2. Quá trình parse CSS
1
2
3
4
5
6
7
8
9
10
11
12
body {
font-size: 16px;
}
h1 {
color: blue;
font-size: 32px;
}
p {
color: gray;
}CSSOM Tree:
View Mermaid diagram code
flowchart TD
Root[CSSOM Root] --> Body["body<br/>font-size: 16px"]
Body --> H1["h1<br/>color: blue<br/>font-size: 32px (tự khai báo)"]
Body --> P["p<br/>color: gray<br/>font-size: 16px (kế thừa từ body)"]Giải thích:
- CSSOM có cấu trúc cây, các thuộc tính kế thừa chảy từ cha xuống con (
font-sizecủabodytruyền xuống) - Trừ khi con tự ghi đè:
h1khai báofont-size: 32pxnên không lấy 16px từbody pchỉ đặtcolor, nênfont-sizekế thừa 16px từbody- Nhờ vậy, khi ghép với DOM ở bước sau, mỗi element biết chính xác computed style của nó
3.3. CSS render blocking
Điểm quan trọng: CSS là render blocking - trình duyệt không render bất cứ nội dung nào cho đến khi CSSOM hoàn thành.
1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="styles.css" />
<!-- Blocking render -->
<link rel="stylesheet" href="print.css" media="print" />
<!-- Không chặn render trên màn hình -->
</head>
<body>
<h1>Hello World</h1>
</body>
</html>Giải thích:
styles.css: Chặn render vìmediamặc định áp dụng cho màn hìnhprint.css: Không chặn render màn hình vì chỉ dùng khi in (vẫn được tải, nhưng ưu tiên thấp)- Trình duyệt tải các file CSS song song, nhưng phải đợi parse xong CSSOM mới render
Tại sao CSS phải blocking?
1
2
3
4
5
6
<style>
h1 {
display: none;
}
</style>
<h1>This heading should be hidden</h1>Nếu trình duyệt render trước khi CSSOM sẵn sàng, người dùng sẽ thấy <h1> hiện lên rồi biến mất (flash of unstyled content - FOUC) - trải nghiệm tệ hơn việc đợi thêm một chút.
4. Bước 3: Xây Dựng Render Tree
Render Tree kết hợp DOM và CSSOM, chỉ chứa những gì sẽ hiển thị.
4.1. Từ DOM + CSSOM đến Render Tree
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<html>
<head>
<style>
p {
display: none;
}
span {
color: red;
}
</style>
</head>
<body>
<div>
<h1>Hello</h1>
<p>Hidden paragraph</p>
<span>Visible span</span>
</div>
</body>
</html>Render Tree chỉ chứa:
View Mermaid diagram code
flowchart TD
HTML[html] --> Body[body]
Body --> Div[div]
Div --> H1["h1: 'Hello'"]
Div --> Span["span: 'Visible span'<br/>color: red"]Giải thích:
<head>và toàn bộ nội dung không hiển thị bị loại khỏi Render Tree<p>bị loại vìdisplay: none(không có box để vẽ)- Mỗi node còn lại đi kèm computed style đã resolve từ CSSOM (ví dụ
spancócolor: red) - Render Tree vì thế là “bản đồ những gì thực sự được vẽ”, không phải toàn bộ DOM
4.2. Phân biệt display: none vs visibility: hidden
Hai thuộc tính này cùng ẩn element nhưng khác nhau ở Render Tree:
1
2
3
4
5
6
.hidden-display {
display: none;
}
.hidden-visibility {
visibility: hidden;
}1
2
<div class="hidden-display">Not in Render Tree</div>
<div class="hidden-visibility">In Render Tree but invisible</div>Giải thích:
display: none: Bị loại khỏi Render Tree, không tạo box, không chiếm không gianvisibility: hidden: Vẫn nằm trong Render Tree, vẫn tạo box và chiếm không gian - chỉ là không vẽ pixel- Hệ quả: bật/tắt
displaylàm layout tính lại (reflow), cònvisibilitychỉ ảnh hưởng bước paint
5. Bước 4: Layout (Reflow)
Layout tính toán vị trí và kích thước chính xác của mỗi element dựa trên box model.
1
2
3
4
5
6
7
8
9
10
11
12
13
<style>
.container {
width: 800px;
padding: 20px;
}
.box {
width: 50%;
margin: 10px;
}
</style>
<div class="container">
<div class="box">Content</div>
</div>Layout calculation:
1
2
3
4
// Trình duyệt tính toán (giả sử box-sizing mặc định: content-box):
// Container: 800px content + 20px padding × 2 cạnh = 840px tổng chiều rộng
// Box: width 50% tính theo content width của container = 50% × 800px = 400px
// Box position: x = 20px (padding container) + 10px (margin box) = 30px; y = 30pxGiải thích:
- Layout đi từ root xuống (top-down): cha phải có kích thước trước, con mới tính được
- Giá trị
%phải đợi cha layout xong mới resolve (nhưwidth: 50%của box) - Phụ thuộc viewport và kích thước cha, nên đổi kích thước cửa sổ sẽ trigger layout lại (reflow)
6. Bước 5: Paint
Paint là bước tô pixel cho từng element - background, border, text, shadow… Thứ tự tô tuân theo stacking order, trong đó z-index quyết định element nào nằm trên.
6.1. Paint order
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<style>
.background {
background: blue;
}
.text {
position: relative;
color: white;
z-index: 1;
}
.overlay {
position: relative;
background: rgba(0, 0, 0, 0.5);
z-index: 2;
}
</style>
<div class="background">
<p class="text">Text content</p>
<div class="overlay">Overlay</div>
</div>Thứ tự paint:
View Mermaid diagram code
sequenceDiagram
participant Browser
participant Screen
Browser->>Browser: 1. Tô background (blue)
Browser->>Browser: 2. Tô text (white, z-index: 1)
Browser->>Browser: 3. Tô overlay (đen mờ, z-index: 2)
Note over Browser: Composite các layer
Browser->>Screen: Hiển thị kết quả cuốiGiải thích:
- Element có
z-indexthấp được tô trước, cao tô sau (nằm chồng lên trên) z-indexchỉ có tác dụng khi element đã positioned (positionkhácstatic) hoặc là flex/grid item; nếu không, browser tô theo thứ tự xuất hiện trong document- Sau khi tô xong các phần, browser composite (ghép) chúng lại thành khung hình cuối
6.2. Composite layers
Một số property được xử lý thẳng ở bước composite nên animation mượt hơn hẳn:
1
2
3
4
5
6
7
8
9
10
/* Chỉ chạy ở compositor - nhanh */
.fast-animation {
transform: translateX(100px);
will-change: transform;
}
/* Kéo theo layout + paint - chậm */
.slow-animation {
margin-left: 100px;
}Giải thích:
transform,opacity: browser xử lý ở compositor, bỏ qua layout và paint → rất nhanhmargin,width,left: đổi giá trị kéo theo layout, rồi paint, rồi composite → tốn kémwill-change: transformgợi ý browser tách sẵn layer trước, tránh giật khi animation bắt đầu (dùng tiết chế, đừng lạm dụng)
7. Tối Ưu Critical Rendering Path
7.1. Tối ưu Critical Resources
Ba metrics quan trọng:
- Critical Resources: Số lượng resources chặn render
- Critical Bytes: Tổng dung lượng critical resources
- Critical Path Length: Số round trips cần thiết
Trước (3 critical resources):
1
2
3
4
5
<head>
<link rel="stylesheet" href="styles.css" /> <!-- Critical -->
<script src="analytics.js"></script> <!-- Critical nhưng không cần thiết -->
<script src="app.js"></script> <!-- Critical -->
</head>- Preload scanner phát hiện cả 3 resources ngay và tải chúng song song, nên vấn đề không nằm ở số round trips khi download
styles.css: CSS luôn render-blocking - browser phải tải và parse xong mới render đượcanalytics.jsvàapp.js: script thường bắt parser đứng chờ ngay tại vị trí của nó - dừng parse HTML để execute, và nếu có CSS phía trên thì còn phải đợi CSSOM xong mới chạy. Chính phần chờ tuần tự này (không phải download) mới là nút thắt
Sau (1 critical resource):
1
2
3
4
5
<head>
<link rel="stylesheet" href="styles.css" /> <!-- Critical -->
<script async src="analytics.js"></script> <!-- Non-blocking -->
<script defer src="app.js"></script> <!-- Non-blocking -->
</head>styles.css: Vẫn critical vì CSS cần thiết cho visual renderinganalytics.jsvớiasync: Tải song song, execute ngay khi tải xong - chỉ chặn parsing tạm thời trong lúc execute, không bắt parser đứng chờ như script thườngapp.jsvớidefer: Tải song song, execute sau khi HTML parsed - không block render
Kết quả cải thiện:
| Metric | Trước | Sau | Cải thiện |
|---|---|---|---|
| Critical Resources | 3 | 1 | -67% |
| Parser đứng chờ script (tải) | Có | Không | Đáng kể |
| Time to First Render | Chậm | Nhanh hơn | Đáng kể |
Chỉ còn CSS là render-blocking, JavaScript được tải và thực thi mà không làm chậm việc hiển thị trang.
7.2. Inline Critical CSS

Above-the-fold là phần nội dung user nhìn thấy ngay khi trang load, không cần scroll. Đây là phần cần Critical CSS.
Below-the-fold là phần nội dung nằm dưới màn hình, user phải scroll mới thấy. CSS cho phần này có thể tải sau.
Chiến lược tối ưu:
- Above-the-fold: Inline Critical CSS → build CSSOM ngay khi parse HTML, không đợi network
- Below-the-fold: Load CSS async → không block first paint
Cách triển khai:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<head>
<!-- Critical CSS inline để render ngay lập tức -->
<style>
/* Styles cho phần above-the-fold */
.header {
background: #333;
color: white;
}
.main-content {
max-width: 1200px;
margin: 0 auto;
}
</style>
<!-- Preload CSS không critical -->
<link
rel="preload"
href="css/styles.css"
as="style"
onload="this.rel='stylesheet'"
/>
<!-- Fallback cho users không có JavaScript -->
<noscript>
<link rel="stylesheet" href="css/styles.css" />
</noscript>
</head>Giải thích:
Inline Critical CSS
Browser có thể build CSSOM ngay khi parse HTML vì CSS đã nằm sẵn trong document, không cần đợi network request. Điều này giúp Render Tree được tạo sớm hơn.
Async CSS Loading với Preload
rel="preload": Tải CSS với priority cao nhưng không block renderas="style": Báo browser đây là stylesheet, giúp prioritize và cache đúng cáchonload="this.rel='stylesheet'": Khi load xong, chuyển thành stylesheet để apply styles
Fallback cho No-JavaScript
<noscript> đảm bảo users không có JavaScript vẫn nhận được CSS qua thẻ <link> thông thường.
Kết quả: Page render nhanh với critical CSS, full styles load và apply sau mà không làm chậm First Contentful Paint (FCP).
7.3. Async và Defer cho JavaScript
1
2
3
4
5
6
7
<head>
<!-- Script chính của app: dùng defer -->
<script defer src="app.js"></script>
<!-- Script bên thứ 3: dùng async -->
<script async src="https://www.google-analytics.com/analytics.js"></script>
</head>So sánh blocking behavior:
| Loại Script | Parse HTML | Thực thi khi | Block render |
|---|---|---|---|
<script> thường | Bị chặn | Ngay lập tức | Có |
<script async> | Tiếp tục | Khi tải xong | Tạm thời |
<script defer> | Tiếp tục | Sau khi parse HTML | Không |
Giải thích chi tiết:
Để hiểu sâu hơn về async và defer, xem bài viết async vs defer: Chọn Cái Nào Để Web Load Nhanh?.
7.4. Resource Hints
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<head>
<!-- DNS prefetch: Resolve domain trước -->
<link rel="dns-prefetch" href="//fonts.googleapis.com" />
<!-- Preconnect: Thiết lập connection sớm -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<!-- Preload: Tải resource với priority cao -->
<link rel="preload" href="critical.js" as="script" />
<link rel="preload" href="hero-image.jpg" as="image" />
<!-- Prefetch: Tải trước cho trang kế tiếp -->
<link rel="prefetch" href="next-page.js" />
</head>Giải thích:
Bốn hint này khác nhau ở chỗ chúng làm bao nhiêu việc và cho trang nào - từ nhẹ nhất (dns-prefetch) đến nặng nhất (preload).
1. dns-prefetch
- Chỉ làm mỗi bước phân giải tên miền (DNS lookup) sớm cho một domain, để khi thật sự request thì không phải chờ. Đây là hint nhẹ nhất, gần như không tốn tài nguyên.
- Khi nào dùng: Third-party domains bạn chắc chắn sẽ cần nhưng chưa gấp (analytics, CDN, widget bên thứ ba).
2. preconnect
- Đi xa hơn
dns-prefetch: thiết lập trọn vẹn connection gồm DNS lookup + TCP handshake + TLS negotiation. Vì gói cả 3 bước, nó tiết kiệm được khoảng 100-500ms cho request đầu tiên tới domain đó. - Khi nào dùng: Third-party resources critical cần đến sớm. Connection tốn tài nguyên nên chỉ nên preconnect 2-3 domain quan trọng nhất - lạm dụng sẽ phản tác dụng.
3. preload
Bắt browser tải một resource cụ thể của trang hiện tại ngay với priority cao. Thuộc tính
aslà bắt buộc để browser đặt đúng priority và cache đúng cách:as="script": JavaScriptas="style": CSSas="image": hình ảnhas="font": font (cần thêmcrossorigin)
Khi nào dùng: Resource critical cho first render nhưng browser phát hiện muộn vì nằm sâu trong dependency - font khai báo trong CSS, ảnh trong
background-image.
4. prefetch
- Tải trước resource cho trang kế tiếp với priority thấp. Browser chỉ tải lúc rảnh nên không tranh giành băng thông với trang hiện tại.
- Khi nào dùng: Resource của trang mà user nhiều khả năng sẽ đi tới (bước tiếp theo trong wizard, trang chi tiết từ danh sách).
7.5. Code Splitting
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Trước: Load tất cả ngay từ đầu
import { featureA } from "./features";
import { featureB } from "./features";
import { featureC } from "./features";
// Sau: Dynamic import khi cần
const loadFeatureA = () => import("./featureA");
const loadFeatureB = () => import("./featureB");
// Chỉ load khi user click
button.addEventListener("click", async () => {
const module = await loadFeatureA();
module.initialize();
});Giải thích:
1. Static Import vs Dynamic Import
- Static import (trước): mọi thứ bị gộp vào một bundle lớn - user phải tải hết ngay cả phần chưa bao giờ dùng tới.
- Dynamic import (sau): code được tách thành các chunk nhỏ, browser chỉ tải chunk khi thật sự cần (ví dụ khi user click).
2. Lợi ích
- Bundle ban đầu nhỏ hơn: tải và parse nhanh hơn, trang khả dụng sớm hơn
- Time to Interactive tốt hơn: user không phải chờ những đoạn code chưa cần đến
- Cache hiệu quả hơn: sửa một feature chỉ invalidate chunk đó, không phải cả bundle
3. Khi nào nên split
- Theo route: mỗi trang là một chunk riêng
- Theo component: những component nặng và ít khi mở như modal, dialog, dropdown phức tạp
- Theo feature: phần không phải ai cũng dùng (admin panel, export PDF)
1
2
3
// Route-based splitting với React
const Dashboard = React.lazy(() => import("./pages/Dashboard"));
const Settings = React.lazy(() => import("./pages/Settings"));7.6. Image Optimization
Hình ảnh thường chiếm khoảng 40-50% tổng dung lượng trang web, nên đây thường là chỗ tối ưu cho hiệu quả cao nhất. Làm tốt không chỉ tiết kiệm bandwidth mà còn kéo Largest Contentful Paint (LCP) xuống - một trong những Core Web Vitals quan trọng nhất.
Native Lazy Loading
1
2
<!-- Native lazy loading - không cần JavaScript -->
<img src="image.jpg" loading="lazy" alt="Mô tả" />Cách hoạt động:
- Browser hoãn tải hình cho tới khi nó sắp lọt vào viewport - khoảng cách kích hoạt tùy tốc độ mạng (thường ~1250px trên mạng nhanh, xa hơn trên mạng chậm)
- Ảnh above-the-fold đừng dùng
loading="lazy"- việc hoãn tải sẽ làm LCP chậm đi - Hỗ trợ sẵn trên mọi browser hiện đại: Chrome 77+, Firefox 75+, Safari 15.4+ (2022)
1
2
3
4
5
6
<!-- Hero image - KHÔNG lazy load -->
<img src="hero.jpg" alt="Banner chính" fetchpriority="high" />
<!-- Hình dưới fold - lazy load -->
<img src="product-1.jpg" loading="lazy" alt="Sản phẩm 1" />
<img src="product-2.jpg" loading="lazy" alt="Sản phẩm 2" />Responsive Images với srcset/sizes
1
2
3
4
5
6
<img
srcset="small.jpg 400w, medium.jpg 800w, large.jpg 1200w"
sizes="(max-width: 600px) 400px, (max-width: 900px) 800px, 1200px"
src="large.jpg"
alt="Mô tả"
/>Giải thích từng phần:
| Attribute | Ý nghĩa |
|---|---|
srcset | Danh sách các file ảnh kèm width thực (400w = 400 pixels wide) |
sizes | Cho browser biết ảnh sẽ hiển thị ở size nào tùy viewport |
src | Fallback cho browser cũ không hỗ trợ srcset |
Cách browser chọn ảnh:
- Đọc
sizesđể biết ô hiển thị ảnh rộng 400px, 800px hay 1200px - Nhân với device pixel ratio (DPR) - màn Retina có DPR = 2
- Chọn file nhỏ nhất vẫn đủ sắc nét cho kích thước cần
Ví dụ thực tế: viewport 500px khớp (max-width: 600px) 400px → ô hiển thị 400px → DPR 2 cần 400 × 2 = 800px thực → browser chọn medium.jpg 800w
Modern Formats với Picture Element
1
2
3
4
5
6
7
8
<picture>
<!-- AVIF - nhỏ nhất, mọi browser hiện đại đều hỗ trợ -->
<source srcset="image.avif" type="image/avif" />
<!-- WebP - cân bằng giữa size và support -->
<source srcset="image.webp" type="image/webp" />
<!-- JPEG - fallback universal -->
<img src="image.jpg" alt="Mô tả" />
</picture>So sánh format:
| Format | Giảm size vs JPEG | Browser Support |
|---|---|---|
| AVIF | 50-60% | Tất cả browser hiện đại (Chrome 85+, Firefox 93+, Safari 16.4+) |
| WebP | 25-35% | Tất cả browser hiện đại |
| JPEG | baseline | Universal |
Lưu ý: browser đọc các <source> từ trên xuống và dừng ở format đầu tiên nó hỗ trợ - chỉ tải đúng một file, không tải hết. Vì vậy hãy xếp format tốt nhất (AVIF) lên trước.
Preload LCP Image
Với hình quan trọng nhất trang (hero, banner), preload để browser tải sớm thay vì đợi phát hiện qua CSS/JS:
1
2
3
4
5
6
7
8
9
10
<head>
<!-- Preload hero image với responsive -->
<link
rel="preload"
as="image"
href="hero.webp"
imagesrcset="hero-400.webp 400w, hero-800.webp 800w, hero-1200.webp 1200w"
imagesizes="100vw"
/>
</head>Khi nào dùng preload image:
- Hero image, banner chính
- Background image trong CSS (browser phát hiện muộn)
- LCP element là hình ảnh
Placeholder Strategies
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- Dominant color placeholder -->
<img
src="product.jpg"
loading="lazy"
style="background-color: #e8d5b7;"
alt="Sản phẩm"
/>
<!-- Low-quality image placeholder (LQIP) -->
<img
src="product-tiny.jpg"
data-src="product-full.jpg"
class="lazyload blur-up"
alt="Sản phẩm"
/>Trong thực tế, các framework như Next.js Image hay Gatsby Image đã tích hợp sẵn LQIP và hiệu ứng blur-up, kèm luôn lazy-load và tạo srcset tự động - hầu như bạn không cần tự làm những phần trên.
8. Đo Lường Performance
Tối ưu mà không đo lường thì chỉ là đoán mò. Trước khi sửa bất cứ thứ gì, bạn cần biết chính xác bottleneck nằm ở đâu - phần nào của critical path đang kéo chậm màn hình đầu tiên. Hai công cụ dưới đây có sẵn ngay trong Chrome, không cần cài thêm gì.
8.1. Chrome DevTools Performance Tab

Mở DevTools → tab Performance → nhấn Record rồi reload trang. DevTools sẽ ghi lại toàn bộ quá trình dựng trang và chia timeline thành các phase theo màu, giúp bạn nhìn ra ngay đâu là công đoạn tốn thời gian nhất:
- Loading - màu xanh dương (tải và parse HTML)
- Scripting - màu vàng (thực thi JavaScript)
- Rendering - màu tím (tính Style + Layout)
- Painting - màu xanh lá (Paint + Composite)
Nếu thanh màu vàng chiếm phần lớn timeline, JavaScript chính là thủ phạm; nếu màu tím dày đặc, bạn đang gặp vấn đề layout.
Vài mẹo khi đọc timeline:
- Tìm các Long Task (>50ms, có gạch chéo đỏ ở góc) - đây là những tác vụ chặn main thread và làm trang đơ
- Mở tab Bottom-Up để xem hàm nào ngốn thời gian nhất, từ đó biết cần tối ưu đoạn code nào
- Bật Screenshots để thấy trang hiện ra từng bước - rất hữu ích khi xác định thời điểm First Paint và LCP
8.2. Lighthouse Audit
Performance tab cho bạn dữ liệu thô, còn Lighthouse thì tổng hợp lại thành điểm số và gợi ý cụ thể nên sửa gì trước. Trong DevTools, mở tab Lighthouse, chọn category Performance (và Best Practices nếu cần), rồi nhấn Analyze page load.
Report sẽ chấm điểm các Core Web Vitals (LCP, CLS, TBT) và liệt kê danh sách cải thiện kèm ước tính số mili-giây tiết kiệm được. Những gợi ý hay gặp nhất liên quan trực tiếp tới critical path:
- Loại bỏ render-blocking resources (CSS/JS chặn hiển thị)
- Giảm unused CSS/JS không dùng đến
- Đặt đúng kích thước và nén hình ảnh
- Lazy-load hình ảnh ngoài viewport
- Minify CSS/JS
Cứ ưu tiên xử lý những mục có mức tiết kiệm lớn nhất trước, đo lại, rồi lặp - đó là cách tối ưu có định hướng thay vì sửa lung tung.
9. Best Practices Checklist
Gom lại tất cả những gì đã bàn, đây là checklist bạn có thể mở ra rà soát mỗi lần tối ưu một trang. Không cần làm hết cùng lúc - chọn nhóm nào đang là bottleneck (dựa trên số đo ở phần trên) và xử lý trước.
HTML:
- Đặt critical CSS inline trong
<head> - Load non-critical CSS async
- Script với
deferhoặcasync - Preload critical resources
CSS:
- Minify và compress CSS
- Remove unused CSS
- Use media queries để conditional load
- Avoid @import (blocking)
JavaScript:
- Code splitting cho large apps
- Tree shaking để remove dead code
- Lazy load features không cần ngay
- Avoid layout thrashing
Images:
- Lazy load offscreen images
- Serve responsive images với srcset
- Use modern formats (WebP, AVIF)
- Optimize và compress images
Fonts:
- Preload critical fonts
- Use
font-display: swap - Subset fonts (chỉ characters cần dùng)
- Self-host fonts thay vì CDN
Vậy là bạn đã đi hết hành trình từ HTML đến pixels: DOM, CSSOM, Render Tree, Layout rồi Paint. Điều quan trọng cần nhớ là mọi tối ưu Critical Rendering Path đều quy về ba con số - giảm số critical resources, giảm số bytes phải tải, và rút ngắn độ dài chuỗi phụ thuộc. Đừng cố tối ưu tất cả cùng lúc; hãy mở Chrome DevTools lên, đo trước, tìm đúng bottleneck, sửa một chỗ rồi đo lại. Cứ lặp vòng đó, trang của bạn sẽ hiện ra trước mắt người dùng nhanh hơn hẳn.