Hình dung bạn nhận task “tối ưu performance trang chủ”, mở Lighthouse lên, thấy điểm Performance đỏ lòm: 38/100. LCP 8 giây, CLS 0.42, Total Blocking Time gần 3 giây. Phản xạ đầu tiên là vào code bật lazy load tất cả ảnh, minify JS, thêm defer cho mọi script. Deploy lên, Lighthouse lên 52. Vẫn đỏ. Tiếp tục bật gzip, purge CSS thừa, đổi font sang font-display: swap. Lighthouse lên 61. Vẫn chưa xanh.
Sau vài tuần mới nhận ra vấn đề: đang “thử đủ thứ” mà không hiểu cái gì thực sự chậm và tại sao. Tối ưu ảnh nhưng bottleneck thật là render-blocking CSS inline trong <head> tới 180KB. Defer JS nhưng third-party analytics script vẫn load synchronous vì marketing team nhúng trực tiếp vào template. Bật lazy load cho mọi ảnh, kể cả ảnh hero LCP, khiến LCP tệ hơn trước.
Bài học đầu tiên về web performance: đo trước, hiểu bottleneck, rồi hãy sửa. Không có “danh sách 20 thứ cần làm” nào thay thế được việc hiểu trang của bạn chậm ở đâu. Bài viết này trình bày framework tư duy và kỹ thuật cụ thể khi tối ưu performance, thực chiến trên production, không phải checklist lý thuyết.
Core Web Vitals, ba con số Google quan tâm và bạn cũng nên
Core Web Vitals là bộ ba chỉ số mà Google dùng để đánh giá trải nghiệm người dùng, và chúng ảnh hưởng trực tiếp đến SEO ranking từ 2021. Nhưng quan trọng hơn SEO, chúng đo đúng những thứ user thực sự cảm nhận: trang hiện nhanh không, ổn định không, phản hồi nhanh không.
LCP, trang hiển thị nội dung chính khi nào
Largest Contentful Paint đo thời gian từ lúc user bắt đầu navigate đến khi phần tử lớn nhất trong viewport render xong. “Phần tử lớn nhất” thường là ảnh hero, banner, hoặc khối text lớn, thứ mà user nhìn thấy đầu tiên và coi là “trang đã load”.
Google khuyến nghị LCP dưới 2.5 giây. Trên 4 giây là “poor”. Nghe đơn giản nhưng đạt LCP 2.5 giây trên mobile 3G không hề dễ, kéo LCP từ 4.2 giây xuống 2.1 giây cho một trang có ảnh hero và custom font có thể mất nhiều ngày.
LCP bị ảnh hưởng bởi bốn yếu tố chính: thời gian server response (TTFB), thời gian load resource (ảnh, font), render-blocking resources (CSS, JS chặn render), và client-side rendering delay. Mỗi yếu tố cần cách tiếp cận khác nhau, và bạn phải xác định đâu là bottleneck chính trước khi sửa.
Cách hiệu quả nhất là dùng Chrome DevTools Performance tab, record page load, rồi nhìn vào LCP marker trong timeline. Nó cho thấy chính xác element nào là LCP element và quá trình load nó diễn ra thế nào, chờ server bao lâu, download bao lâu, render bao lâu. Từ đó mới biết sửa ở đâu.
INP, trang phản hồi tương tác nhanh không
Interaction to Next Paint (thay thế FID từ tháng 3/2024) đo thời gian từ khi user tương tác (click, tap, keypress) đến khi trình duyệt render visual feedback. Ngưỡng tốt là dưới 200ms.
INP khác FID ở chỗ nó đo mọi interaction trong suốt phiên, không chỉ lần đầu. Trang có thể phản hồi nhanh lần đầu nhưng chậm khi user click nút sau khi JavaScript nặng đã load xong và đang chạy.
Nguyên nhân INP tệ thường là long task trên main thread, JavaScript chạy liên tục hơn 50ms, block browser khỏi render. Event handler nặng, re-render framework phức tạp, hoặc third-party script chiếm main thread đều gây INP cao. Giải pháp thường là break long task bằng requestIdleCallback, scheduler.yield(), hoặc chuyển logic nặng sang Web Worker.
CLS, trang có nhảy loạn không
Cumulative Layout Shift đo tổng mức độ content bị dịch chuyển bất ngờ trong quá trình load. Ngưỡng tốt là dưới 0.1.
CLS là chỉ số mà nhiều dev không để ý cho đến khi user phàn nàn “tôi đang đọc bài thì trang nhảy lên” hoặc “tôi đang bấm nút mua thì nó dịch xuống, bấm nhầm nút khác”. Đây là trải nghiệm cực kỳ khó chịu, đặc biệt trên mobile.
Nguyên nhân CLS phổ biến nhất: ảnh không có width/height attribute (browser không biết kích thước nên khi ảnh load xong, content bị đẩy xuống), font load xong thay đổi kích thước text (FOUT, Flash of Unstyled Text), dynamic content inject vào DOM trên cùng (banner, ad, notification bar), và iframe không có kích thước cố định.
Fix CLS thường đơn giản hơn LCP và INP: đặt width/height hoặc aspect-ratio cho mọi ảnh, dùng font-display: optional hoặc font-display: swap với size-adjust, reserve space cho ad/dynamic content bằng min-height. Nên fix CLS trước vì ROI cao nhất, effort thấp, impact lớn.
Đo trước khi sửa, công cụ và tư duy
Sai lầm phổ biến nhất khi tối ưu performance là nhảy vào sửa code mà không đo trước. Kết quả: tốn thời gian tối ưu thứ không phải bottleneck, rồi không biết thay đổi có thực sự cải thiện không.
Lab data vs Field data
Có hai loại dữ liệu performance và bạn cần cả hai.
Lab data là kết quả từ controlled environment, Lighthouse chạy trên máy bạn, WebPageTest từ server cố định, Chrome DevTools. Ưu điểm: reproducible, chi tiết, debug được. Nhược điểm: không đại diện cho user thật, máy bạn nhanh hơn điện thoại trung bình của user Việt Nam, mạng công ty nhanh hơn 4G user ở vùng tỉnh.
Field data (Real User Monitoring, RUM) là dữ liệu từ user thật, Chrome User Experience Report (CrUX) trong PageSpeed Insights, hoặc RUM tool như web-vitals library, Datadog RUM, New Relic Browser. Ưu điểm: phản ánh trải nghiệm thật. Nhược điểm: aggregate, khó debug từng case.
Dùng lab data để debug và tối ưu (Lighthouse, DevTools Performance tab), rồi theo dõi field data để xác nhận thay đổi thực sự có lợi cho user thật. Lighthouse score 95 mà CrUX vẫn đỏ = chưa xong.
Công cụ nên dùng
Chrome DevTools Performance tab là công cụ chi tiết nhất. Record page load, zoom vào từng frame, thấy main thread đang làm gì, script nào chạy bao lâu, layout/paint xảy ra khi nào. Network tab với throttling 3G cho thấy trải nghiệm trên mạng chậm.
Lighthouse (trong DevTools hoặc CLI) cho overview nhanh và suggestions cụ thể. Nhưng đừng chạy theo điểm Lighthouse, nó chạy trên máy bạn với simulated throttling, không phải thiết bị thật. Hay gặp tình huống trang Lighthouse 95 nhưng CrUX “poor” vì user phần lớn dùng điện thoại Android giá rẻ.
PageSpeed Insights kết hợp cả lab data (Lighthouse) và field data (CrUX). Mục “Field Data” ở đầu trang cho bạn biết user thật trải nghiệm thế nào, nếu mục này xanh thì trang đang ổn, dù lab data có vàng cũng không quá lo.
WebPageTest cho phân tích sâu hơn: filmstrip view (thấy trang render từng bước), waterfall chart chi tiết (từng resource load thứ tự nào), test từ nhiều location và device. Dùng khi cần hiểu rõ loading sequence hoặc so sánh trước/sau optimization.
web-vitals library của Google, embed vào trang, gửi metric về analytics. Đây là cách đơn giản nhất để có RUM data:
import { onLCP, onINP, onCLS } from "web-vitals";
onLCP((metric) => sendToAnalytics("LCP", metric));
onINP((metric) => sendToAnalytics("INP", metric));
onCLS((metric) => sendToAnalytics("CLS", metric));
Quy trình đo-sửa-đo
Quy trình lặp đơn giản mỗi khi tối ưu performance:
Bước một, đo baseline: chạy Lighthouse 3 lần lấy trung bình (kết quả Lighthouse dao động giữa các lần chạy), ghi lại LCP, INP, CLS, Total Blocking Time, Speed Index. Nếu có RUM data, ghi P75 của từng metric.
Bước hai, xác định bottleneck: nhìn vào DevTools Performance recording, tìm thứ chiếm nhiều thời gian nhất trên critical path. Không phải “ảnh chưa tối ưu” hay “JS chưa defer”, mà là cụ thể: “ảnh hero 2.4MB load mất 3 giây trên 3G” hoặc “CSS 180KB render-blocking mất 1.2 giây parse”.
Bước ba, sửa một thứ: chỉ sửa một thứ tại một thời điểm, để biết chính xác thay đổi nào có impact. Sửa 5 thứ cùng lúc rồi Lighthouse tăng 20 điểm, bạn không biết 20 điểm đó đến từ đâu, nếu cần rollback một thay đổi vì bug thì không biết sẽ mất bao nhiêu điểm.
Bước bốn, đo lại: chạy Lighthouse lại, so sánh với baseline. Nếu cải thiện, giữ và commit. Nếu không đổi, thay đổi đó không phải bottleneck, rollback và tìm chỗ khác. Nếu tệ hơn, bạn vừa tạo regression, debug tại sao.
Critical rendering path, hiểu trình duyệt render trang thế nào
Để tối ưu hiệu quả, bạn cần hiểu browser render trang qua những bước nào. Không cần nhớ chi tiết từng micro-step, nhưng nắm flow chính giúp bạn biết thay đổi nào ảnh hưởng ở đâu.
Khi user navigate đến URL, browser gửi HTTP request đến server. Server trả HTML. Browser bắt đầu parse HTML từ trên xuống dưới. Gặp <link rel="stylesheet"> thì fetch CSS, và block rendering cho đến khi CSS download xong và parse xong. Gặp <script src> không có defer/async thì fetch JS, và block cả parsing lẫn rendering cho đến khi JS download và execute xong. Sau khi có DOM tree (từ HTML) và CSSOM tree (từ CSS), browser tạo render tree, tính layout, rồi paint lên màn hình.
Hai từ khoá quan trọng ở đây: render-blocking (CSS) và parser-blocking (JS synchronous). Mọi thứ trên critical rendering path mà chưa load xong sẽ delay render, user thấy trang trắng. Mục tiêu tối ưu là giảm thiểu thứ nằm trên critical path: ít CSS render-blocking hơn, JS defer/async, và resource tải nhanh hơn.
CSS render-blocking, kẻ thù thầm lặng
Mọi <link rel="stylesheet"> trong <head> đều render-blocking, browser sẽ không paint bất kỳ pixel nào cho đến khi download và parse xong tất cả CSS trong head. Với file CSS 200KB trên mạng 3G (khoảng 1.5 Mbps), download mất hơn 1 giây, nghĩa là user thấy trang trắng ít nhất 1 giây chỉ vì CSS.
Giải pháp phổ biến là critical CSS inlining: xác định CSS cần thiết cho above-the-fold content (phần user thấy ngay khi load, không cần scroll), inline trực tiếp vào <style> trong <head>. CSS còn lại load async:
<head>
<style>
/* Critical CSS, chỉ cho above-the-fold */
header {
background: #fff;
height: 60px;
}
.hero {
padding: 2rem;
font-size: 2em;
}
.main-content {
max-width: 800px;
margin: 0 auto;
}
</style>
<link
rel="preload"
href="/css/full.css"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript><link rel="stylesheet" href="/css/full.css" /></noscript>
</head>
Tool critical (npm package) hoặc Critters (webpack plugin) tự động extract critical CSS từ HTML. Critters trong build pipeline là lựa chọn tốt, không cần maintain critical CSS thủ công.
Một tip nhỏ mà ít người biết: CSS có thể load conditional bằng media attribute. Nếu có CSS chỉ dùng cho print hoặc cho màn hình rộng, load với media attribute phù hợp, browser vẫn download nhưng không coi là render-blocking cho media hiện tại:
<link rel="stylesheet" href="/css/print.css" media="print" />
<link rel="stylesheet" href="/css/desktop.css" media="(min-width: 1024px)" />
JavaScript parser-blocking, defer là bạn thân
Script synchronous (<script src="..."> không có attribute) block cả parsing lẫn rendering. Browser dừng parse HTML, chờ download JS, execute xong mới tiếp tục parse phần HTML còn lại. Trên trang có 5 script synchronous, mỗi cái 50KB, tổng delay có thể vài giây.
defer nói browser “download song song với parsing HTML, nhưng chỉ execute sau khi HTML parse xong, theo thứ tự”. async nói “download song song, execute ngay khi download xong, không đảm bảo thứ tự”.
Quy tắc nên theo: mọi script đều defer trừ khi có lý do cụ thể để không. Script phụ thuộc DOM order thì defer (giữ thứ tự). Script độc lập (analytics, tracking) thì async. Script cần chạy ngay (polyfill, critical initialization) thì inline trong <script> tag:
<!-- Defer cho app scripts, chạy sau khi HTML parse xong, giữ thứ tự -->
<script src="/js/vendor.js" defer></script>
<script src="/js/app.js" defer></script>
<!-- Async cho analytics, không ảnh hưởng app, chạy khi nào cũng được -->
<script src="/js/analytics.js" async></script>
Tối ưu ảnh, phần chiếm nhiều bandwidth nhất
Ảnh chiếm trung bình 50-60% trọng lượng trang web. Tối ưu ảnh thường cho ROI cao nhất trong tất cả các kỹ thuật performance, giảm vài trăm KB ảnh dễ hơn nhiều so với giảm 50KB JavaScript.
Chi tiết về format ảnh đã có trong bài riêng, nên ở đây tập trung vào những điểm ảnh hưởng trực tiếp đến page performance.
Responsive images, serve đúng size cho đúng device
Sai lầm phổ biến nhất: serve ảnh gốc 4000×3000 cho mọi device. Điện thoại 400px chiều ngang phải tải ảnh 4MB rồi browser resize xuống, lãng phí bandwidth khủng khiếp.
Dùng srcset và sizes để browser chọn ảnh vừa đủ:
<img
src="/img/hero-800.jpg"
srcset="
/img/hero-400.jpg 400w,
/img/hero-800.jpg 800w,
/img/hero-1600.jpg 1600w
"
sizes="(min-width: 1024px) 50vw, 100vw"
alt="Hero banner"
width="1600"
height="900"
loading="eager"
fetchpriority="high"
/>
Kết hợp với <picture> element cho format fallback (AVIF → WebP → JPEG), bạn có combo tối ưu nhất: đúng format + đúng size cho đúng device. Chi tiết pattern này được trình bày kỹ trong bài về format ảnh.
Lazy load đúng cách, không lazy ảnh LCP
loading="lazy" là tính năng native browser cực mạnh, ảnh ngoài viewport không tải cho đến khi user gần scroll đến. Nhưng có một sai lầm hay lặp đi lặp lại: đặt loading="lazy" cho ảnh LCP.
Ảnh LCP (hero banner, ảnh sản phẩm chính) là ảnh user cần thấy ngay. Đặt lazy cho nó nghĩa là browser hoãn tải, LCP tăng 500-1500ms trên mạng thật. Ảnh LCP phải loading="eager" (hoặc không set gì, eager là mặc định), kết hợp fetchpriority="high" và preload:
<link
rel="preload"
as="image"
imagesrcset="/img/hero-800.avif 800w, /img/hero-1600.avif 1600w"
imagesizes="(min-width: 1024px) 50vw, 100vw"
type="image/avif"
/>
Quy tắc đơn giản: ảnh above-the-fold → eager. Ảnh below-the-fold → lazy. Không biết chắc → DevTools Performance tab cho bạn thấy element nào là LCP.
Width/height, fix CLS đơn giản nhất
Luôn đặt width và height attribute trên <img> (hoặc aspect-ratio trong CSS). Thiếu kích thước thì browser không biết reserve bao nhiêu space, khi ảnh load xong, content bị đẩy xuống, gây CLS. Đây là nguyên nhân CLS phổ biến nhất và fix đơn giản nhất, mà nhiều dev vẫn quên.
Tối ưu font, ít hơn bạn nghĩ
Font web ảnh hưởng đến cả LCP (text là LCP element thì font load delay = LCP delay) và CLS (font load xong text thay đổi kích thước). Tối ưu font không phức tạp nhưng cần làm đúng.
Font-display, kiểm soát hành vi hiển thị
@font-face {
font-family: "MainFont";
src: url("/fonts/main.woff2") format("woff2");
font-display: swap;
}
font-display: swap hiện text bằng fallback font ngay, rồi swap sang custom font khi load xong. User thấy content ngay (tốt cho LCP) nhưng text nhảy khi font swap (tệ cho CLS).
font-display: optional cho browser quyết định: nếu font load đủ nhanh (thường dưới 100ms) thì dùng, nếu không thì skip và dùng fallback cả phiên. Tốt nhất cho CLS vì không bao giờ swap, nhưng có thể user không thấy custom font lần đầu.
Nên chọn swap cho heading font (user cần thấy text ngay) và optional cho body font nếu CLS là vấn đề. Nếu dùng swap, cần thêm size-adjust để fallback font gần kích thước custom font, giảm layout shift khi swap:
@font-face {
font-family: "MainFont Fallback";
src: local("Arial");
size-adjust: 105%;
ascent-override: 95%;
line-gap-override: 0%;
}
Preload font quan trọng
Font thường được discover muộn, browser phải download CSS, parse CSS, tìm @font-face, rồi mới bắt đầu download font. Preload shortcut quá trình này:
<link
rel="preload"
href="/fonts/main.woff2"
as="font"
type="font/woff2"
crossorigin
/>
Chỉ preload 1-2 font quan trọng nhất, preload nhiều quá thì tranh bandwidth với resource khác. Và luôn có crossorigin attribute (bắt buộc cho font preload, kể cả self-hosted).
Giảm số font
Mỗi font file thêm vào là thêm request, thêm bytes, thêm delay. Nên giới hạn tối đa 2 font family (heading + body), mỗi font tối đa 2-3 weight (regular, medium, bold). Variable font là lựa chọn tốt nếu cần nhiều weight, một file chứa tất cả weight, browser download một lần.
Nếu brand cho phép, dùng system font stack thay vì custom font, zero download, zero delay:
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
}
Tối ưu JavaScript, main thread là tài nguyên quý nhất
JavaScript ảnh hưởng performance theo hai cách: download size (bandwidth) và execution time (main thread). Cả hai đều quan trọng nhưng nhiều dev chỉ tập trung vào size mà quên execution time.
Code splitting, chỉ tải JS cần thiết
Bundle 1 file JavaScript 500KB cho mọi page nghĩa là user truy cập trang About cũng phải download JS cho trang Checkout. Code splitting chia bundle theo route hoặc feature:
// Dynamic import, webpack/vite tự tách thành chunk riêng
button.addEventListener("click", async () => {
const { HeavyFeature } = await import("./heavy-feature.js");
HeavyFeature.init();
});
Framework hiện đại (Next.js, Nuxt, Remix, SvelteKit) code split tự động theo route. Nếu dùng vanilla webpack/vite, cần configure splitChunks hợp lý, vendor chunk (React, lodash) tách riêng để cache lâu, app chunk thay đổi thường xuyên hơn.
Tree shaking, bỏ code thừa
Import cả thư viện khi chỉ cần một function là lãng phí:
// Tệ, import toàn bộ lodash (70KB+)
import _ from "lodash";
_.debounce(fn, 300);
// Tốt, chỉ import function cần dùng (3KB)
import debounce from "lodash/debounce";
debounce(fn, 300);
Modern bundler (webpack production mode, vite, rollup) tree shake tự động cho ESM imports. Nhưng chỉ hoạt động khi library export đúng cách (ESM, không side effects). CommonJS (require) không tree shakeable.
Long task và main thread blocking
JavaScript chạy trên main thread, cùng thread render UI. Nếu một đoạn JS chạy liên tục 200ms (long task), browser không thể respond click của user trong 200ms đó, INP tệ.
Chrome DevTools Performance tab highlight long task bằng khối đỏ. Record interaction (click nút, scroll) rồi xem có long task nào block. Giải pháp thường là break task lớn thành nhiều task nhỏ:
// Trước: long task 300ms
function processAllItems(items) {
items.forEach((item) => heavyProcess(item)); // 300ms liên tục
}
// Sau: yield main thread giữa các batch
async function processAllItems(items) {
const BATCH = 50;
for (let i = 0; i < items.length; i += BATCH) {
const batch = items.slice(i, i + BATCH);
batch.forEach((item) => heavyProcess(item));
// Nhường main thread cho browser xử lý event/render
await new Promise((r) => setTimeout(r, 0));
}
}
Web Worker là giải pháp mạnh hơn cho logic nặng (sort data lớn, image processing, crypto), chạy trên thread riêng, không block main thread. Nhưng Web Worker giao tiếp qua message passing (postMessage), không access DOM, nên chỉ phù hợp cho pure computation.
Bundle analysis, biết JS nào chiếm bao nhiêu
Trước khi tối ưu JS size, cần biết cái gì chiếm nhiều nhất. webpack-bundle-analyzer hoặc source-map-explorer visualize bundle, hay gặp trường hợp moment.js chiếm 70KB (gồm locale không dùng), đổi sang date-fns giảm xuống 8KB cho cùng chức năng. Không analyze thì không biết, cảm giác “bundle hình như to” không giúp gì.
Tối ưu CSS, nhẹ hơn bạn tưởng
CSS thường nhỏ hơn JS nhiều, nhưng vì nó render-blocking nên impact lớn hơn tương đối. 50KB CSS render-blocking delay render hơn 100KB JS defer.
Purge CSS không dùng
Framework CSS utility (Tailwind, Bootstrap) ship hàng trăm class. Nếu bạn chỉ dùng 20% thì 80% CSS thừa. PurgeCSS (hoặc Tailwind built-in purge) scan HTML/JS tìm class đang dùng, loại bỏ phần còn lại. Giảm CSS từ 350KB xuống 28KB chỉ bằng bật purge cho Tailwind là chuyện thường gặp, impact rất lớn vì CSS render-blocking.
// tailwind.config.js
module.exports = {
content: ["./src/**/*.{html,js,jsx,tsx}"],
// Tailwind v3+ tự purge dựa vào content config
};
Minify CSS
cssnano hoặc lightningcss minify CSS, loại bỏ whitespace, comment, shorthand property. Tiết kiệm 10-30% file size. Build pipeline nào cũng nên có bước này.
CSS containment
contain property nói browser rằng element này độc lập về layout/paint, thay đổi bên trong không ảnh hưởng bên ngoài. Browser có thể optimize layout/paint cho phần đó:
.card {
contain: layout style paint;
}
content-visibility: auto mạnh hơn, browser skip render element ngoài viewport hoàn toàn, chỉ render khi user scroll gần. Rất hiệu quả cho trang dài nhiều section:
.below-fold-section {
content-visibility: auto;
contain-intrinsic-size: auto 500px;
}
Caching strategy, tải một lần, dùng nhiều lần
Cache đúng giảm bandwidth đáng kể cho returning user. Lần đầu user tải 1MB assets. Lần sau tải 0 bytes nếu cache đúng, trang load gần như instant.
Cache-Control headers
Hai pattern chính:
Immutable assets (file có content hash trong tên: app.a1b2c3.js, styles.d4e5f6.css): cache dài hạn, không cần revalidate.
Cache-Control: public, max-age=31536000, immutable
HTML và asset không hash (index.html, API responses): cache ngắn hoặc revalidate mỗi lần.
Cache-Control: no-cache
no-cache không có nghĩa “không cache”, nó nghĩa “cache nhưng phải revalidate với server trước khi dùng” (bằng ETag hoặc Last-Modified). Nếu file chưa đổi, server trả 304 Not Modified (0 bytes body). Nếu đã đổi, trả 200 với file mới.
Preconnect và DNS prefetch
Nếu trang load resource từ domain khác (CDN, font server, analytics), browser phải DNS resolve + TCP connect + TLS handshake trước khi download. Preconnect shortcut quá trình này:
<!-- Preconnect cho CDN ảnh -->
<link rel="preconnect" href="https://cdn.example.com" />
<!-- DNS prefetch cho domain ít quan trọng hơn -->
<link rel="dns-prefetch" href="https://analytics.example.com" />
preconnect mở connection sẵn (DNS + TCP + TLS), tốn bandwidth nhưng tiết kiệm thời gian khi request thật đến. Chỉ dùng cho 2-3 domain quan trọng nhất. dns-prefetch chỉ resolve DNS, nhẹ hơn, dùng cho domain phụ.
Third-party scripts, kẻ phá hoại thầm lặng
Đây là phần nhiều dev bất lực nhất. Bạn tối ưu code của bạn đẹp đẽ, nhưng marketing team nhúng Google Tag Manager, Facebook Pixel, Hotjar, Intercom widget, tổng cộng 500KB JS third-party chạy trên main thread, LCP tăng 2 giây, INP tệ vì script chiếm main thread.
Đo impact trước
Trước khi tranh luận với stakeholder về việc bỏ script nào, đo impact cụ thể. Chrome DevTools Performance tab → record page load → xem third-party scripts chiếm bao nhiêu main thread time. Coverage tab (Ctrl+Shift+P → “Show Coverage”) cho biết bao nhiêu % JS/CSS được sử dụng, third-party script thường có coverage dưới 30%.
WebPageTest có option “Block” domain, block từng third-party domain rồi so sánh LCP/TBT. Bạn có số liệu cụ thể: “bỏ Intercom cải thiện LCP 800ms” thuyết phục hơn “third-party script nặng quá”.
Giảm thiểu impact
Nếu không thể bỏ script (business requirement), giảm impact:
<!-- Load GTM async, mặc định nó đã async nhưng verify HTML -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script>
<!-- Delay third-party script cho đến khi user tương tác -->
<script>
document.addEventListener(
"scroll",
function loadThirdParty() {
// Load Intercom, Hotjar, etc. chỉ khi user scroll
const s = document.createElement("script");
s.src = "https://widget.intercom.io/widget/xxx";
document.body.appendChild(s);
document.removeEventListener("scroll", loadThirdParty);
},
{ once: true }
);
</script>
Facade pattern cho widget nặng: hiện placeholder (nút chat giả, iframe giả) cho đến khi user click. Lúc đó mới load widget thật. YouTube embed là ví dụ kinh điển, đặt thumbnail với play button, click mới load iframe YouTube:
<div class="youtube-facade" data-video-id="dQw4w9WgXcQ">
<img src="/img/youtube-thumb.jpg" alt="Video title" loading="lazy" />
<button aria-label="Play video">▶</button>
</div>
<script>
document
.querySelector(".youtube-facade")
.addEventListener("click", function () {
const iframe = document.createElement("iframe");
iframe.src = `https://www.youtube.com/embed/${this.dataset.videoId}?autoplay=1`;
iframe.allow = "autoplay; encrypted-media";
this.replaceWith(iframe);
});
</script>
Pattern này phù hợp cho mọi embed nặng: YouTube, Google Maps, social widgets. Trang load nhanh hơn đáng kể vì không có iframe hay script nặng cho đến khi user thực sự cần.
Server-side, TTFB và compression
Mọi tối ưu phía client đều vô nghĩa nếu server response chậm. TTFB (Time to First Byte) là baseline, browser không thể bắt đầu render cho đến khi nhận byte đầu tiên của HTML.
TTFB, giảm thời gian server response
TTFB tốt nên dưới 800ms, lý tưởng dưới 200ms. Nếu TTFB cao, kiểm tra theo thứ tự: server processing time (database query chậm, business logic nặng), server location (server ở US mà user ở VN thì RTT đã 200ms+), CDN (HTML có cache ở edge không).
Đối với static site (Hugo, Next.js static export, Astro), TTFB thường rất tốt vì HTML là file tĩnh, deploy lên CDN (Cloudflare Pages, Vercel, Netlify) là TTFB vài chục ms ở mọi nơi. Với server-rendered page, cần cache HTML ở CDN edge hoặc tối ưu server rendering time.
Compression, gzip và Brotli
Text-based resources (HTML, CSS, JS, JSON, SVG) nên compress. Gzip giảm 60-80% size. Brotli (mới hơn, support rộng) giảm thêm 15-25% so với gzip ở cùng compression level.
# Nginx, Brotli preferred, gzip fallback
brotli on;
brotli_types text/plain text/css application/json
application/javascript text/xml application/xml
image/svg+xml;
brotli_comp_level 6;
gzip on;
gzip_types text/plain text/css application/json
application/javascript text/xml application/xml;
Hầu hết CDN và hosting (Cloudflare, Vercel, Netlify) bật compression tự động. Kiểm tra bằng DevTools Network tab, cột “Size” vs “Content” cho biết compressed vs uncompressed size. Nếu hai cột bằng nhau, compression chưa bật.
HTTP/2 và HTTP/3
HTTP/2 multiplexing cho phép browser download nhiều resource song song trên cùng TCP connection, khác HTTP/1.1 chỉ cho 6 connection song song per domain. HTTP/3 (QUIC) cải thiện thêm với UDP-based transport, giảm latency cho mobile và mạng kém.
Hầu hết CDN và web server hiện đại support HTTP/2+ mặc định. Kiểm tra bằng DevTools Network tab, cột “Protocol” hiện h2 hoặc h3. Nếu vẫn http/1.1, server cần cấu hình lại.
Với HTTP/2, không cần sprite sheet hay concatenate CSS/JS vào một file nữa, multiplexing handle song song tốt. Nhưng vẫn cần giảm tổng số request hợp lý vì mỗi request vẫn có overhead (header, compression context).
Service Worker, cache offline và performance
Service Worker chặn mọi network request từ trang web, cho phép bạn cache response và serve từ cache mà không cần mạng. Với returning user, trang có thể load gần instant vì mọi asset đã có trong Service Worker cache.
// sw.js, cache-first strategy cho static assets
const CACHE = "v1";
const PRECACHE = ["/", "/css/main.css", "/js/app.js"];
self.addEventListener("install", (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(PRECACHE)));
});
self.addEventListener("fetch", (e) => {
e.respondWith(
caches.match(e.request).then((cached) => cached || fetch(e.request))
);
});
Nhưng Service Worker cũng là nguồn bug performance nếu dùng sai. Cache strategy sai (cache-first cho API mà data thay đổi thường xuyên) khiến user thấy data cũ. Precache quá nhiều asset làm install event chậm. Service Worker update không đúng cách khiến user stuck ở version cũ.
Workbox (Google library) là lựa chọn tốt hơn viết Service Worker thủ công, nó cung cấp sẵn các cache strategy (cache-first, network-first, stale-while-revalidate) và handle update gracefully. Chỉ dùng cho static site hoặc app shell architecture, dynamic page render server-side thì Service Worker phức tạp hơn giá trị nó mang lại.
Prefetch và prerender, đoán trước bước tiếp
Nếu bạn biết user có khả năng cao navigate đến trang nào tiếp theo, prefetch hoặc prerender trang đó trong background:
<!-- Prefetch, download resource, chưa render -->
<link rel="prefetch" href="/likely-next-page.html" />
<!-- Speculation Rules API (Chrome 109+), prerender thực sự -->
<script type="speculationrules">
{
"prerender": [{ "where": { "href_matches": "/products/*" } }]
}
</script>
Speculation Rules API là tính năng mới cho phép prerender trang trong hidden tab, khi user click, trang hiện ngay instant (0ms LCP). Nhưng cần cẩn thận: prerender tốn bandwidth và memory. Chỉ prerender khi probability cao (user hover link, hoặc page analytics cho thấy 60%+ user navigate đến trang đó).
Monitoring liên tục, không phải tối ưu một lần là xong
Performance regression xảy ra liên tục: developer thêm library mới, marketing thêm script mới, designer thêm font mới, third-party update SDK nặng hơn. Nếu không monitor, LCP sẽ từ từ tăng lên mà không ai để ý cho đến khi user phàn nàn.
Performance budget
Đặt ngân sách performance: “Total JS < 200KB gzipped”, “LCP < 2.5s on 3G”, “CLS < 0.1”. Build pipeline fail nếu vượt budget:
// webpack config, size limit
performance: {
maxAssetSize: 250000, // 250KB per asset
maxEntrypointSize: 500000, // 500KB per entrypoint
hints: "error" // fail build nếu vượt
}
Lighthouse CI chạy trong CI/CD pipeline, compare với baseline, fail PR nếu performance tụt. Đây là cách duy nhất ngăn regression hiệu quả, review thủ công không scale.
RUM dashboard
web-vitals library gửi metric về analytics backend. Build dashboard P75 cho LCP, INP, CLS, phân segment theo device type (mobile vs desktop), connection type (3G/4G/wifi), và page type (homepage, product, checkout). P75 vì Google dùng P75 cho CrUX ranking.
Alert khi metric vượt ngưỡng: LCP P75 > 3s, CLS P75 > 0.15, INP P75 > 250ms. Khi alert reo, check xem commit nào gần nhất có thể gây regression, thường là thêm dependency mới hoặc thay đổi loading order.
Case study, tối ưu trang chủ từ 38 lên 92 Lighthouse
Quay lại project đầu bài. Sau khi áp dụng quy trình đo-sửa-đo, đây là timeline thực tế:
Tuần 1: đo baseline, xác định 3 bottleneck chính. Bottleneck lớn nhất: CSS inline trong template 180KB render-blocking. Thứ hai: ảnh hero PNG 2.4MB không responsive. Thứ ba: Google Analytics và Facebook Pixel load synchronous.
Tuần 2: extract critical CSS bằng Critters (50 dòng config), move phần còn lại sang async load. LCP giảm từ 8s xuống 4.5s. Lighthouse từ 38 lên 58.
Tuần 3: convert ảnh hero sang AVIF + WebP + JPEG fallback, thêm srcset cho 3 kích thước, preload ảnh LCP. LCP giảm từ 4.5s xuống 2.8s. Lighthouse lên 71.
Tuần 4: defer analytics scripts, facade pattern cho social widgets, thêm width/height cho mọi ảnh. LCP xuống 2.1s, CLS từ 0.42 xuống 0.03. Lighthouse lên 92.
Tổng effort: 4 tuần part-time. Kết quả field data (CrUX) cải thiện sau 28 ngày, LCP P75 từ 6.2s xuống 2.4s, CLS P75 từ 0.35 xuống 0.05. Bounce rate giảm 15%.
Điều đáng chú ý: không sửa code business logic nào cả. Không refactor component. Không đổi framework. Chỉ tối ưu delivery, cách resource được load, cache, và render. Performance optimization phần lớn là delivery optimization, không phải code optimization.
Anti-pattern hay gặp
Lazy load mọi thứ. Lazy load ảnh LCP gây regression LCP. Lazy load content above-the-fold gây delay visible content. Chỉ lazy load thứ ngoài viewport.
Tối ưu theo cảm tính. “Hình như ảnh nặng” → optimize ảnh. Nhưng bottleneck thật là CSS render-blocking. Đo trước, sửa đúng chỗ.
Chạy theo Lighthouse score. Lighthouse 95 nhưng CrUX “poor” = user thật vẫn chậm. Lighthouse chạy trên máy bạn, không đại diện cho thiết bị user. Theo dõi field data.
Preload quá nhiều. Preload 10 resource tranh bandwidth lẫn nhau. Chỉ preload LCP resource và 1-2 critical resource khác.
Quên third-party. Tối ưu code của bạn hết cỡ nhưng third-party script chiếm 60% main thread time. Đo impact, facade pattern, delay load.
Cache-busting sai. Deploy file mới với cùng URL nhưng CDN cache cũ → user thấy version cũ. Dùng content hash trong filename (app.abc123.js) và cache immutable. HTML không cache dài, nó reference đến asset URL mới.
Không set performance budget. Mọi người thêm dependency thoải mái, bundle phình từ từ, không ai để ý cho đến khi Lighthouse đỏ lòm. Budget + CI check ngăn regression tự động.
Tóm tắt
Web performance optimization bắt đầu bằng đo lường, không bằng sửa code. Xác định bottleneck thực sự trên critical rendering path, thường là CSS render-blocking, ảnh chưa tối ưu, hoặc third-party scripts, rồi sửa từng thứ một, đo lại sau mỗi thay đổi.
Core Web Vitals (LCP, INP, CLS) là framework đánh giá: LCP dưới 2.5s, INP dưới 200ms, CLS dưới 0.1 là ngưỡng tốt. Đạt trong lab data chưa đủ, field data (RUM, CrUX) phản ánh trải nghiệm user thật, đó mới là thước đo cuối cùng.
Ảnh tối ưu (đúng format, đúng size, lazy load đúng chỗ) cho ROI cao nhất. Font giới hạn số lượng và preload font critical. JS code split + defer + break long task. CSS critical inline + purge unused. Cache đúng strategy, immutable cho hashed assets, revalidate cho HTML.
Third-party scripts là kẻ phá hoại thầm lặng, đo impact cụ thể, facade pattern cho widget nặng, delay load cho script không critical. Và quan trọng nhất: đặt performance budget trong CI để ngăn regression, vì performance không phải tối ưu một lần rồi xong, đó là cuộc chiến hàng ngày với mỗi dependency mới, mỗi feature mới, mỗi script mới được thêm vào.