Core Web Vitals năm 2026: Những Gì Thực Sự Tạo Ra Khác Biệt
Quên lý thuyết đi — đây là những gì tôi thực sự đã làm để đưa LCP xuống dưới 2.5s, CLS về 0, và INP dưới 200ms trên trang Next.js production thực tế.
Tôi đã dành phần lớn hai tuần để làm trang này nhanh. Không phải "trông nhanh trong Lighthouse audit trên MacBook M3 của tôi" nhanh. Thực sự nhanh. Nhanh trên điện thoại Android giá 150 đô trên kết nối 4G chập chờn trong đường hầm tàu điện ngầm. Nhanh ở nơi thực sự quan trọng.
Kết quả: LCP dưới 1.8s, CLS ở 0.00, INP dưới 120ms. Cả ba đều xanh trong dữ liệu CrUX, không chỉ điểm lab. Và tôi đã học được điều gì đó trong quá trình — hầu hết lời khuyên về hiệu suất trên internet hoặc đã lỗi thời, mơ hồ, hoặc cả hai.
"Tối ưu hóa hình ảnh" không phải lời khuyên. "Dùng lazy loading" mà không có ngữ cảnh là nguy hiểm. "Giảm thiểu JavaScript" là hiển nhiên nhưng không nói cho bạn biết phải cắt cái gì.
Đây là những gì tôi thực sự đã làm, theo thứ tự quan trọng.
Tại Sao Core Web Vitals Vẫn Quan Trọng Năm 2026#
Để tôi nói thẳng: Google dùng Core Web Vitals làm tín hiệu xếp hạng. Không phải tín hiệu duy nhất, và thậm chí không phải quan trọng nhất. Mức độ liên quan nội dung, backlink, và quyền hạn domain vẫn thống trị. Nhưng ở ranh giới — nơi hai trang có nội dung và quyền hạn tương đương — hiệu suất là yếu tố phân định. Và trên internet, hàng triệu trang sống ở những ranh giới đó.
Nhưng hãy quên SEO một giây. Lý do thực sự để quan tâm đến hiệu suất là người dùng. Dữ liệu không thay đổi nhiều trong năm năm qua:
- 53% lượt truy cập mobile bị bỏ nếu trang mất hơn 3 giây để tải (nghiên cứu Google/SOASTA, vẫn đúng)
- Mỗi 100ms độ trễ tiêu tốn khoảng 1% tỷ lệ chuyển đổi (phát hiện ban đầu của Amazon, được xác nhận nhiều lần)
- Người dùng trải nghiệm layout shift ít có khả năng hơn đáng kể hoàn thành mua hàng hoặc điền form
Core Web Vitals năm 2026 gồm ba chỉ số:
| Chỉ Số | Đo Cái Gì | Tốt | Cần Cải Thiện | Kém |
|---|---|---|---|---|
| LCP | Hiệu suất tải | ≤ 2.5s | 2.5s – 4.0s | > 4.0s |
| CLS | Ổn định thị giác | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
| INP | Khả năng phản hồi | ≤ 200ms | 200ms – 500ms | > 500ms |
Các ngưỡng này không thay đổi kể từ khi INP thay thế FID vào tháng 3 năm 2024. Nhưng các kỹ thuật để đạt chúng đã phát triển, đặc biệt trong hệ sinh thái React/Next.js.
LCP: Chỉ Số Quan Trọng Nhất#
Largest Contentful Paint đo khi phần tử hiển thị lớn nhất trong viewport hoàn tất render. Với hầu hết trang, đó là hero image, heading, hoặc khối text lớn.
Bước 1: Tìm Phần Tử LCP Thật Của Bạn#
Trước khi tối ưu bất cứ thứ gì, bạn cần biết phần tử LCP của bạn là gì. Mọi người giả định đó là hero image. Đôi khi đó là web font render thẻ <h1>. Đôi khi đó là background image áp dụng qua CSS. Đôi khi đó là poster frame của <video>.
Mở Chrome DevTools, vào panel Performance, ghi lại page load, và tìm marker "LCP". Nó cho bạn biết chính xác phần tử nào kích hoạt LCP.
Bạn cũng có thể dùng thư viện web-vitals để log theo chương trình:
import { onLCP } from "web-vitals";
onLCP((metric) => {
console.log("LCP element:", metric.entries[0]?.element);
console.log("LCP value:", metric.value, "ms");
});Trên trang này, phần tử LCP hóa ra là hero image trên trang chủ và đoạn văn đầu tiên trên bài blog. Hai phần tử khác nhau, hai chiến lược tối ưu khác nhau.
Bước 2: Preload Tài Nguyên LCP#
Nếu phần tử LCP của bạn là hình ảnh, điều có tác động lớn nhất bạn có thể làm là preload nó. Mặc định, trình duyệt phát hiện hình ảnh khi phân tích HTML, nghĩa là request hình ảnh không bắt đầu cho đến khi HTML được tải xuống, phân tích, và thẻ <img> được đến. Preload chuyển việc phát hiện đó đến ngay từ đầu.
Trong Next.js, bạn có thể thêm link preload trong layout hoặc page:
import Head from "next/head";
export default function HeroSection() {
return (
<>
<Head>
<link
rel="preload"
as="image"
href="/images/hero-optimized.webp"
type="image/webp"
fetchPriority="high"
/>
</Head>
<section className="relative h-[600px]">
<img
src="/images/hero-optimized.webp"
alt="Hero banner"
width={1200}
height={600}
fetchPriority="high"
decoding="sync"
/>
</section>
</>
);
}Chú ý fetchPriority="high". Đây là Fetch Priority API mới hơn, và nó là game changer. Không có nó, trình duyệt dùng heuristic riêng để ưu tiên tài nguyên — và những heuristic đó thường sai, đặc biệt khi bạn có nhiều hình ảnh above the fold.
Trên trang này, thêm fetchPriority="high" vào hình ảnh LCP giảm LCP ~400ms. Đó là chiến thắng lớn nhất tôi từng có từ một thay đổi một dòng.
Bước 3: Loại Bỏ Tài Nguyên Chặn Render#
CSS chặn rendering. Tất cả. Nếu bạn có stylesheet 200KB được tải qua <link rel="stylesheet">, trình duyệt sẽ không vẽ gì cho đến khi nó được tải xuống và phân tích hoàn toàn.
Cách sửa gồm ba phần:
-
Inline CSS quan trọng — Trích xuất CSS cần cho nội dung above-the-fold và inline nó trong thẻ
<style>trong<head>. Next.js tự động làm điều này khi bạn dùng CSS Module hoặc Tailwind với purging đúng cách. -
Defer CSS không quan trọng — Nếu bạn có stylesheet cho nội dung below-the-fold (thư viện animation footer, component biểu đồ), tải chúng bất đồng bộ:
<link
rel="preload"
href="/styles/charts.css"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
<link rel="stylesheet" href="/styles/charts.css" />
</noscript>- Xóa CSS không dùng — Tailwind CSS v4 tự động làm điều này với JIT engine. Nhưng nếu bạn đang import thư viện CSS bên thứ ba, hãy kiểm tra chúng. Tôi tìm thấy một thư viện component import 180KB CSS cho một component tooltip duy nhất. Thay nó bằng component tùy chỉnh 20 dòng và tiết kiệm 170KB.
Bước 4: Thời Gian Phản Hồi Server (TTFB)#
LCP không thể nhanh nếu TTFB chậm. Nếu server mất 800ms để phản hồi, LCP của bạn sẽ ít nhất 800ms + mọi thứ khác.
Trên trang này (Node.js + PM2 + Nginx trên VPS), tôi đo TTFB khoảng 180ms ở lần truy cập lạnh. Đây là những gì tôi đã làm để giữ nó ở đó:
- ISR (Incremental Static Regeneration) cho bài blog — trang được pre-render lúc build và revalidate định kỳ. Lần truy cập đầu phục vụ file tĩnh trực tiếp từ cache proxy ngược Nginx.
- Header cache edge —
Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400cho trang tĩnh. - Nén Gzip/Brotli trong Nginx — giảm kích thước truyền 60-80%.
# nginx.conf snippet
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
gzip_comp_level 6;
# Brotli (nếu module ngx_brotli được cài)
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml;
brotli_comp_level 6;Trước/sau LCP của tôi:
- Trước tối ưu: 3.8s (phân vị 75, CrUX)
- Sau preload + fetchPriority + nén: 1.8s
- Tổng cải thiện: giảm 53%
CLS: Chết Bởi Hàng Nghìn Lần Dịch Chuyển#
Cumulative Layout Shift đo mức nội dung hiển thị di chuyển trong quá trình tải trang. CLS bằng 0 nghĩa là không có gì dịch chuyển. CLS trên 0.1 nghĩa là có thứ gì đó đang làm phiền mắt người dùng.
CLS là chỉ số hầu hết developer đánh giá thấp. Bạn không nhận thấy nó trên máy phát triển nhanh với mọi thứ đã cache. Người dùng nhận thấy nó trên điện thoại, trên kết nối chậm, nơi font tải muộn và hình ảnh xuất hiện từng cái một.
Thủ Phạm Thường Gặp#
1. Hình ảnh không có kích thước rõ ràng
Đây là nguyên nhân CLS phổ biến nhất. Khi hình ảnh tải, nó đẩy nội dung bên dưới xuống. Cách sửa đơn giản đến xấu hổ: luôn chỉ định width và height trên thẻ <img>.
// XẤU — gây layout shift
<img src="/photo.jpg" alt="Team photo" />
// TỐT — trình duyệt dành chỗ trước khi hình ảnh tải
<img src="/photo.jpg" alt="Team photo" width={800} height={450} />Nếu bạn dùng Next.js <Image>, nó xử lý tự động miễn là bạn cung cấp kích thước hoặc dùng fill với container cha có kích thước.
Nhưng đây là bẫy: nếu bạn dùng mode fill, container cha phải có kích thước rõ ràng hoặc hình ảnh sẽ gây CLS:
// XẤU — cha không có kích thước
<div className="relative">
<Image src="/photo.jpg" alt="Team" fill />
</div>
// TỐT — cha có tỷ lệ khung hình rõ ràng
<div className="relative aspect-video w-full">
<Image src="/photo.jpg" alt="Team" fill sizes="100vw" />
</div>2. Web font gây FOUT/FOIT
Khi font tùy chỉnh tải, text render bằng font dự phòng được render lại bằng font tùy chỉnh. Nếu hai font có metric khác nhau (hầu như luôn luôn), mọi thứ dịch chuyển.
Cách sửa hiện đại là font-display: swap kết hợp với font dự phòng điều chỉnh kích thước:
// Dùng next/font — cách tiếp cận tốt nhất cho Next.js
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
// next/font tự động tạo font dự phòng điều chỉnh kích thước
// Điều này loại bỏ CLS từ font swap
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}next/font thực sự là một trong những thứ tốt nhất trong Next.js. Nó tải font lúc build, tự host (không có request Google Fonts lúc runtime), và tạo font dự phòng điều chỉnh kích thước để việc swap từ font dự phòng sang font tùy chỉnh gây zero layout shift. Tôi đo CLS từ font ở 0.00 sau khi chuyển sang next/font. Trước đó, với <link> Google Fonts tiêu chuẩn, nó là 0.04-0.08.
3. Chèn nội dung động
Quảng cáo, cookie banner, thanh thông báo — bất cứ thứ gì được chèn vào DOM sau render ban đầu gây CLS nếu nó đẩy nội dung xuống.
Cách sửa: dành chỗ cho nội dung động trước khi nó tải.
// Cookie banner — dành chỗ ở dưới cùng
function CookieBanner() {
const [accepted, setAccepted] = useState(false);
if (accepted) return null;
return (
// Position fixed không gây CLS vì nó
// không ảnh hưởng document flow
<div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900 p-4">
<p>Chúng tôi dùng cookie. Bạn biết rồi đó.</p>
<button onClick={() => setAccepted(true)}>Chấp nhận</button>
</div>
);
}Dùng position: fixed hoặc position: absolute cho phần tử động là cách tiếp cận không gây CLS vì những phần tử này không ảnh hưởng document flow bình thường.
4. Thủ thuật CSS aspect-ratio
Cho container responsive nơi bạn biết tỷ lệ khung hình nhưng không biết kích thước chính xác, dùng thuộc tính CSS aspect-ratio:
// Video embed không CLS
function VideoEmbed({ src }: { src: string }) {
return (
<div className="w-full aspect-video bg-gray-900 rounded-lg overflow-hidden">
<iframe
src={src}
className="w-full h-full"
title="Embedded video"
allow="accelerometer; autoplay; clipboard-write; encrypted-media"
allowFullScreen
/>
</div>
);
}Tiện ích aspect-video (tức aspect-ratio: 16/9) dành đúng lượng không gian cần thiết. Không dịch chuyển khi iframe tải.
5. Skeleton screen
Cho nội dung tải bất đồng bộ (dữ liệu API, component động), hiển thị skeleton khớp với kích thước dự kiến:
function PostCardSkeleton() {
return (
<div className="animate-pulse rounded-lg border p-4">
<div className="h-48 w-full rounded bg-gray-200" />
<div className="mt-4 space-y-2">
<div className="h-6 w-3/4 rounded bg-gray-200" />
<div className="h-4 w-full rounded bg-gray-200" />
<div className="h-4 w-5/6 rounded bg-gray-200" />
</div>
</div>
);
}
function PostList() {
const { data: posts, isLoading } = usePosts();
if (isLoading) {
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
);
}
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts?.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}Điều then chốt là PostCardSkeleton và PostCard nên có cùng kích thước. Nếu skeleton cao 200px và card thật cao 280px, bạn vẫn bị dịch chuyển.
Kết quả CLS của tôi:
- Trước: 0.12 (riêng font swap đã là 0.06)
- Sau: 0.00 — theo đúng nghĩa đen là zero, qua hàng nghìn lượt tải trang trong dữ liệu CrUX
INP: Kẻ Mới Đến Có Nanh#
Interaction to Next Paint thay thế First Input Delay vào tháng 3 năm 2024, và nó là chỉ số khó tối ưu hơn cơ bản. FID chỉ đo độ trễ trước khi tương tác đầu tiên được xử lý. INP đo mọi tương tác xuyên suốt vòng đời trang và báo cáo cái tệ nhất (ở phân vị 75).
Điều này nghĩa là một trang có thể có FID tuyệt vời nhưng INP kinh khủng nếu, chẳng hạn, click vào dropdown menu 30 giây sau khi tải kích hoạt reflow 500ms.
Nguyên Nhân INP Cao#
- Task dài trên main thread — Bất kỳ thực thi JavaScript nào mất hơn 50ms đều chặn main thread. Tương tác người dùng xảy ra trong task dài phải đợi.
- Re-render tốn kém trong React — Cập nhật state khiến 200 component re-render mất thời gian. Người dùng click thứ gì đó, React reconcile, và paint không xảy ra trong 300ms.
- Layout thrashing — Đọc thuộc tính layout (như
offsetHeight) rồi ghi chúng (như thay đổistyle.height) trong vòng lặp buộc trình duyệt tính lại layout đồng bộ. - DOM lớn — Nhiều DOM node hơn nghĩa là tính lại style và layout chậm hơn. DOM với 5,000 node chậm hơn đáng kể so với 500.
Chia Nhỏ Task Dài Với scheduler.yield()#
Kỹ thuật có tác động lớn nhất cho INP là chia nhỏ task dài để trình duyệt có thể xử lý tương tác người dùng giữa các chunk:
async function processLargeDataset(items: DataItem[]) {
const results: ProcessedItem[] = [];
for (let i = 0; i < items.length; i++) {
results.push(expensiveTransform(items[i]));
// Mỗi 10 item, nhường cho trình duyệt
// Điều này cho phép tương tác người dùng đang chờ được xử lý
if (i % 10 === 0 && "scheduler" in globalThis) {
await scheduler.yield();
}
}
return results;
}scheduler.yield() có sẵn trong Chrome 129+ (tháng 9 năm 2024) và là cách được khuyến nghị để nhường main thread. Cho trình duyệt không hỗ trợ, bạn có thể dùng wrapper setTimeout(0):
function yieldToMain(): Promise<void> {
if ("scheduler" in globalThis && "yield" in scheduler) {
return scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}useTransition Cho Cập Nhật Không Khẩn Cấp#
React 18+ cho chúng ta useTransition, nói với React rằng một số cập nhật state không khẩn cấp và có thể bị gián đoạn bởi công việc quan trọng hơn (như phản hồi input người dùng):
import { useState, useTransition } from "react";
function SearchableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const [filteredItems, setFilteredItems] = useState(items);
const [isPending, startTransition] = useTransition();
function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
// Cập nhật này khẩn cấp — input phải phản ánh phím bấm ngay lập tức
setQuery(value);
// Cập nhật này KHÔNG khẩn cấp — lọc 10,000 item có thể đợi
startTransition(() => {
const filtered = items.filter((item) =>
item.name.toLowerCase().includes(value.toLowerCase())
);
setFilteredItems(filtered);
});
}
return (
<div>
<input
type="text"
value={query}
onChange={handleSearch}
placeholder="Tìm kiếm..."
className="w-full rounded border px-4 py-2"
/>
{isPending && (
<p className="mt-2 text-sm text-gray-500">Đang lọc...</p>
)}
<ul className="mt-4 space-y-2">
{filteredItems.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}Không có startTransition, gõ trong ô tìm kiếm sẽ cảm giác ì vì React sẽ cố lọc 10,000 item đồng bộ trước khi cập nhật DOM. Với startTransition, input cập nhật ngay lập tức, và việc lọc xảy ra ở background.
Tôi đo INP trên trang tool có input handler phức tạp. Trước useTransition: 380ms INP. Sau: 90ms INP. Đó là cải thiện 76% từ một thay đổi React API.
Debounce Input Handler#
Cho handler kích hoạt thao tác tốn kém (API call, tính toán nặng), debounce chúng:
import { useCallback, useRef } from "react";
function useDebounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number
): T {
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
return useCallback(
((...args: unknown[]) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => fn(...args), delay);
}) as T,
[fn, delay]
);
}
// Sử dụng
function LiveSearch() {
const [results, setResults] = useState<SearchResult[]>([]);
const search = useDebounce(async (query: string) => {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
setResults(data.results);
}, 300);
return (
<input
type="text"
onChange={(e) => search(e.target.value)}
placeholder="Tìm kiếm..."
/>
);
}300ms là giá trị debounce quen thuộc của tôi. Đủ ngắn để người dùng không nhận thấy độ trễ, đủ dài để tránh phát mỗi lần gõ phím.
Web Worker Cho Tính Toán Nặng#
Nếu bạn có tính toán thực sự nặng (parse JSON lớn, thao tác hình ảnh, tính toán phức tạp), chuyển nó hoàn toàn khỏi main thread:
// worker.ts
self.addEventListener("message", (event) => {
const { data, operation } = event.data;
switch (operation) {
case "sort": {
// Có thể mất 500ms cho dataset lớn
const sorted = data.sort((a: number, b: number) => a - b);
self.postMessage({ result: sorted });
break;
}
case "filter": {
const filtered = data.filter((item: DataItem) =>
complexFilterLogic(item)
);
self.postMessage({ result: filtered });
break;
}
}
});// useWorker.ts
import { useEffect, useRef, useCallback } from "react";
function useWorker() {
const workerRef = useRef<Worker>();
useEffect(() => {
workerRef.current = new Worker(
new URL("../workers/worker.ts", import.meta.url)
);
return () => workerRef.current?.terminate();
}, []);
const process = useCallback(
(operation: string, data: unknown): Promise<unknown> => {
return new Promise((resolve) => {
if (!workerRef.current) return;
workerRef.current.onmessage = (event) => {
resolve(event.data.result);
};
workerRef.current.postMessage({ operation, data });
});
},
[]
);
return { process };
}Web Worker hoạt động trên thread riêng, nên ngay cả tính toán 2 giây cũng không ảnh hưởng INP chút nào. Main thread vẫn rảnh để xử lý tương tác người dùng.
Kết quả INP của tôi:
- Trước: 340ms (tương tác tệ nhất là tool regex tester với xử lý input phức tạp)
- Sau useTransition + debouncing: 110ms
- Cải thiện: giảm 68%
Các Chiến Thắng Đặc Thù Next.js#
Nếu bạn đang dùng Next.js (13+ với App Router), bạn có quyền truy cập vào một số primitive hiệu suất mạnh mẽ mà hầu hết developer không khai thác hết.
next/image — Nhưng Cấu Hình Đúng Cách#
next/image tuyệt vời, nhưng cấu hình mặc định bỏ lỡ hiệu suất:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200],
imageSizes: [16, 32, 48, 64, 96, 128, 256],
minimumCacheTTL: 60 * 60 * 24 * 365, // 1 năm
},
};
export default nextConfig;Cài đặt chính:
formats: ["image/avif", "image/webp"]— AVIF nhỏ hơn WebP 20-50%. Thứ tự quan trọng: Next.js thử AVIF trước, dự phòng WebP, rồi format gốc.minimumCacheTTL— Mặc định là 60 giây. Cho blog, hình ảnh không đổi. Cache chúng một năm.deviceSizesvàimageSizes— Mặc định bao gồm 3840px. Trừ khi bạn phục vụ hình ảnh 4K, cắt danh sách này. Mỗi kích thước tạo một hình ảnh cache riêng, và kích thước không dùng lãng phí dung lượng đĩa và thời gian build.
Và luôn dùng prop sizes để cho trình duyệt biết hình ảnh sẽ render ở kích thước nào:
// Hero image toàn chiều rộng
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
sizes="100vw"
priority // Hình ảnh LCP — đừng lazy load!
/>
// Card image trong grid responsive
<Image
src="/card.jpg"
alt="Card"
width={400}
height={300}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>Không có sizes, trình duyệt có thể tải hình ảnh 1200px cho slot 300px. Đó là byte lãng phí và thời gian lãng phí.
Prop priority trên hình ảnh LCP rất quan trọng. Nó tắt lazy loading và tự động thêm fetchPriority="high". Nếu phần tử LCP của bạn là next/image, chỉ cần thêm priority và bạn đã đi được phần lớn.
next/font — Font Không Layout Shift#
Tôi đã đề cập ở phần CLS, nhưng nó đáng được nhấn mạnh. next/font là giải pháp tải font duy nhất tôi thấy đạt zero CLS nhất quán:
import { Inter, JetBrains_Mono } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-mono",
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="en"
className={`${inter.variable} ${jetbrainsMono.variable}`}
>
<body className="font-sans">{children}</body>
</html>
);
}Hai font, zero CLS, zero request ngoài lúc runtime. Font được tải lúc build và phục vụ từ domain riêng.
Streaming Với Suspense#
Đây là nơi Next.js trở nên thực sự thú vị cho hiệu suất. Với App Router, bạn có thể stream các phần của trang đến trình duyệt khi chúng sẵn sàng:
import { Suspense } from "react";
import { PostList } from "@/components/blog/PostList";
import { Sidebar } from "@/components/blog/Sidebar";
import { PostListSkeleton } from "@/components/blog/PostListSkeleton";
import { SidebarSkeleton } from "@/components/blog/SidebarSkeleton";
export default function BlogPage() {
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
<div className="lg:col-span-2">
{/* Cái này tải nhanh — stream ngay lập tức */}
<h1 className="text-4xl font-bold">Blog</h1>
{/* Cái này cần truy vấn database — stream khi sẵn sàng */}
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
</div>
<aside>
{/* Sidebar có thể tải độc lập */}
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</aside>
</div>
);
}Trình duyệt nhận shell (heading, navigation, layout) ngay lập tức. Danh sách bài và sidebar stream vào khi dữ liệu sẵn sàng. Người dùng thấy tải ban đầu nhanh, và nội dung điền vào dần dần.
Điều này đặc biệt mạnh cho LCP. Nếu phần tử LCP là heading (không phải danh sách bài), nó render ngay lập tức bất kể truy vấn database mất bao lâu.
Cấu Hình Route Segment#
Next.js cho phép bạn cấu hình cache và revalidation ở cấp route segment:
// app/blog/page.tsx
// Revalidate trang này mỗi giờ
export const revalidate = 3600;
// app/tools/[slug]/page.tsx
// Trang tool hoàn toàn tĩnh — generate lúc build
export const dynamic = "force-static";
// app/api/search/route.ts
// API route — không bao giờ cache
export const dynamic = "force-dynamic";Trên trang này, bài blog dùng revalidate = 3600 (1 giờ). Trang tool dùng force-static vì nội dung không đổi giữa các lần deploy. API search dùng force-dynamic vì mỗi request là duy nhất.
Kết quả: hầu hết trang phục vụ từ static cache, TTFB dưới 50ms cho trang đã cache, và server hầu như không đổ mồ hôi.
Công Cụ Đo Lường: Tin Dữ Liệu, Không Phải Mắt Bạn#
Nhận thức của bạn về hiệu suất không đáng tin. Máy phát triển của bạn có 32GB RAM, SSD NVMe, và kết nối gigabit. Người dùng thì không.
Bộ Công Cụ Đo Lường Tôi Dùng#
1. Chrome DevTools Performance Panel
Công cụ chi tiết nhất có sẵn. Ghi page load, nhìn flamechart, xác định task dài, tìm tài nguyên chặn render. Đây là nơi tôi dành phần lớn thời gian debug.
Những thứ chính cần tìm:
- Góc đỏ trên task = task dài (>50ms)
- Sự kiện Layout/Paint kích hoạt bởi JavaScript
- Khối "Evaluate Script" lớn (quá nhiều JavaScript)
- Network waterfall hiển thị tài nguyên phát hiện muộn
2. Lighthouse
Tốt cho kiểm tra nhanh, nhưng đừng tối ưu cho điểm Lighthouse. Lighthouse chạy trong môi trường throttle mô phỏng không khớp hoàn hảo với điều kiện thực tế. Tôi đã thấy trang đạt 98 trong Lighthouse và có LCP 4s ngoài thực tế.
Dùng Lighthouse cho hướng dẫn định hướng, không phải bảng điểm.
3. PageSpeed Insights
Công cụ quan trọng nhất cho trang production vì nó hiển thị dữ liệu CrUX thực — đo lường thực tế từ người dùng Chrome thật trong 28 ngày qua. Dữ liệu lab cho bạn biết có thể xảy ra gì. Dữ liệu CrUX cho bạn biết thực sự xảy ra gì.
4. Thư Viện web-vitals
Thêm nó vào trang production để thu thập metric người dùng thật:
// components/analytics/WebVitals.tsx
"use client";
import { useEffect } from "react";
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
import type { Metric } from "web-vitals";
function sendToAnalytics(metric: Metric) {
// Gửi đến endpoint analytics
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // "good" | "needs-improvement" | "poor"
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
// Dùng sendBeacon để không chặn page unload
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/vitals", body);
} else {
fetch("/api/vitals", {
body,
method: "POST",
keepalive: true,
});
}
}
export function WebVitals() {
useEffect(() => {
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
}, []);
return null;
}Điều này cho bạn dữ liệu giống CrUX riêng, nhưng chi tiết hơn. Bạn có thể phân đoạn theo trang, loại thiết bị, tốc độ kết nối, vùng địa lý — bất cứ thứ gì bạn cần.
5. Chrome User Experience Report (CrUX)
Dataset CrUX BigQuery miễn phí và chứa dữ liệu cuộn 28 ngày cho hàng triệu origin. Nếu trang bạn có đủ traffic, bạn có thể truy vấn dữ liệu riêng:
SELECT
origin,
p75_lcp,
p75_cls,
p75_inp,
form_factor
FROM
`chrome-ux-report.materialized.metrics_summary`
WHERE
origin = 'https://yoursite.com'
AND yyyymm = 202603Danh Sách Tiêu Diệt Waterfall#
Script bên thứ ba là kẻ giết hiệu suất số một trên hầu hết website. Đây là những gì tôi tìm thấy và đã làm gì.
Google Tag Manager (GTM)#
GTM bản thân ~80KB. Nhưng GTM tải các script khác — analytics, marketing pixel, công cụ A/B testing. Tôi đã thấy cấu hình GTM tải thêm 15 script tổng cộng 2MB.
Cách tiếp cận của tôi: Đừng dùng GTM trong production. Tải script analytics trực tiếp, defer mọi thứ, và dùng loading="lazy" cho script có thể đợi:
// Thay vì GTM tải mọi thứ
// Chỉ tải những gì bạn cần, khi bạn cần
export function AnalyticsScript() {
return (
<script
defer
src="https://analytics.example.com/script.js"
data-website-id="your-id"
/>
);
}Nếu bạn nhất định phải dùng GTM, tải nó sau khi trang interactive:
"use client";
import { useEffect } from "react";
export function DeferredGTM({ containerId }: { containerId: string }) {
useEffect(() => {
// Đợi sau khi trang tải xong mới inject GTM
const timer = setTimeout(() => {
const script = document.createElement("script");
script.src = `https://www.googletagmanager.com/gtm.js?id=${containerId}`;
script.async = true;
document.head.appendChild(script);
}, 3000); // trễ 3 giây
return () => clearTimeout(timer);
}, [containerId]);
return null;
}Đúng, bạn sẽ mất dữ liệu từ người dùng bounce trong 3 giây đầu. Theo kinh nghiệm của tôi, đó là đánh đổi đáng làm. Những người dùng đó dù sao cũng không chuyển đổi.
Widget Chat#
Widget chat trực tiếp (Intercom, Drift, Crisp) là những thủ phạm tệ nhất. Riêng Intercom tải hơn 400KB JavaScript. Trên trang mà 2% người dùng thực sự click nút chat, đó là 400KB JavaScript cho 98% người dùng.
Giải pháp của tôi: Tải widget khi có tương tác.
"use client";
import { useState } from "react";
export function ChatButton() {
const [loaded, setLoaded] = useState(false);
function loadChat() {
if (loaded) return;
// Tải script widget chat chỉ khi người dùng click
const script = document.createElement("script");
script.src = "https://chat-widget.example.com/widget.js";
script.onload = () => {
// Khởi tạo widget sau khi script tải
window.ChatWidget?.open();
};
document.head.appendChild(script);
setLoaded(true);
}
return (
<button
onClick={loadChat}
className="fixed bottom-4 right-4 rounded-full bg-blue-600 p-4 text-white shadow-lg"
aria-label="Mở chat"
>
{loaded ? "Đang tải..." : "Chat với chúng tôi"}
</button>
);
}JavaScript Không Dùng#
Chạy Coverage trong Chrome DevTools (Ctrl+Shift+P > "Show Coverage"). Nó cho bạn thấy chính xác bao nhiêu phần trăm mỗi script thực sự được dùng trên trang hiện tại.
Trên trang Next.js điển hình, tôi thường tìm thấy:
- Thư viện component tải toàn bộ — Bạn import
Buttontừ thư viện UI, nhưng toàn bộ thư viện được bundle. Giải pháp: dùng thư viện tree-shakeable hoặc import từ subpath (import Button from "lib/Button"thay vìimport { Button } from "lib"). - Polyfill cho trình duyệt hiện đại — Kiểm tra xem bạn có đang ship polyfill cho
Promise,fetch, hayArray.prototype.includeskhông. Năm 2026, bạn không cần chúng. - Feature flag chết — Đường dẫn code sau feature flag đã "bật" sáu tháng. Xóa flag và nhánh chết.
Tôi dùng Next.js bundle analyzer để tìm chunk quá lớn:
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
const nextConfig = {
// config của bạn
};
export default process.env.ANALYZE === "true"
? withBundleAnalyzer({ enabled: true })(nextConfig)
: nextConfig;ANALYZE=true npm run buildĐiều này mở treemap trực quan của bundle. Tôi tìm thấy thư viện format ngày 120KB mà tôi thay bằng Intl.DateTimeFormat gốc. Tôi tìm thấy markdown parser 90KB import trên trang không dùng markdown. Chiến thắng nhỏ tích lũy lại.
CSS Chặn Render#
Tôi đã đề cập ở phần LCP, nhưng đáng nhắc lại vì nó rất phổ biến. Mỗi <link rel="stylesheet"> trong <head> chặn rendering. Nếu bạn có năm stylesheet, trình duyệt đợi cả năm trước khi vẽ bất cứ gì.
Next.js với Tailwind xử lý tốt — CSS được inline và tối thiểu. Nhưng nếu bạn đang import CSS bên thứ ba, kiểm tra nó:
// XẤU — tải toàn bộ CSS thư viện trên mọi trang
import "some-library/dist/styles.css";
// TỐT HƠN — dynamic import nên chỉ tải trên trang cần
const SomeComponent = dynamic(
() => import("some-library").then((mod) => {
// CSS được import bên trong dynamic component
import("some-library/dist/styles.css");
return mod.SomeComponent;
}),
{ ssr: false }
);Một Câu Chuyện Tối Ưu Thực Tế#
Hãy để tôi đi qua quá trình tối ưu thực tế trang tools của site này. Đó là trang với hơn 15 tool tương tác, mỗi cái có component riêng, và một số (như regex tester và JSON formatter) nặng JavaScript.
Điểm Xuất Phát#
Đo lường ban đầu (dữ liệu CrUX, mobile, phân vị 75):
- LCP: 3.8s — Kém
- CLS: 0.12 — Cần Cải Thiện
- INP: 340ms — Kém
Điểm Lighthouse: 62.
Điều Tra#
Phân tích LCP: Phần tử LCP là heading trang (<h1>), lẽ ra phải render tức thì. Nhưng nó bị trễ bởi:
- File CSS 200KB từ thư viện component (chặn render)
- Font tùy chỉnh tải qua CDN Google Fonts (FOIT 800ms trên kết nối chậm)
- TTFB 420ms vì trang được server-render mỗi request không có cache
Phân tích CLS: Ba nguồn:
- Font swap từ Google Fonts dự phòng sang font tùy chỉnh: 0.06
- Tool card tải không có dành chỗ chiều cao: 0.04
- Cookie banner được chèn ở đầu trang, đẩy mọi thứ xuống: 0.02
Phân tích INP: Tool regex tester là thủ phạm tệ nhất. Mỗi phím bấm trong ô regex kích hoạt:
- Re-render toàn bộ tool component
- Đánh giá regex trên chuỗi test
- Syntax highlighting mẫu regex
Tổng thời gian mỗi phím bấm: 280-400ms.
Các Bản Sửa#
Tuần 1: LCP và CLS
-
Thay CDN Google Fonts bằng
next/font. Font giờ tự host, tải lúc build, với font dự phòng điều chỉnh kích thước. CLS từ font: 0.06 -> 0.00 -
Xóa CSS thư viện component. Viết lại 3 component tôi đang dùng bằng Tailwind. Tổng CSS xóa: 180KB. CSS chặn render: loại bỏ
-
Thêm
revalidate = 3600vào trang tools và trang chi tiết tool. Lần đầu server-render, các lần sau phục vụ từ cache. TTFB: 420ms -> 45ms (đã cache) -
Thêm kích thước rõ ràng cho tất cả component tool card và dùng
aspect-ratiocho layout responsive. CLS từ card: 0.04 -> 0.00 -
Chuyển cookie banner sang
position: fixedở dưới màn hình. CLS từ banner: 0.02 -> 0.00
Tuần 2: INP
- Bọc tính toán kết quả regex tester trong
startTransition:
function RegexTester() {
const [pattern, setPattern] = useState("");
const [testString, setTestString] = useState("");
const [results, setResults] = useState<RegexResult[]>([]);
const [isPending, startTransition] = useTransition();
function handlePatternChange(value: string) {
setPattern(value); // Khẩn cấp: cập nhật input
startTransition(() => {
// Không khẩn cấp: tính toán match
try {
const regex = new RegExp(value, "g");
const matches = [...testString.matchAll(regex)];
setResults(
matches.map((m) => ({
match: m[0],
index: m.index ?? 0,
groups: m.groups,
}))
);
} catch {
setResults([]);
}
});
}
return (
<div>
<input
value={pattern}
onChange={(e) => handlePatternChange(e.target.value)}
className={isPending ? "opacity-70" : ""}
/>
{/* render kết quả */}
</div>
);
}INP trên regex tester: 380ms -> 85ms
-
Thêm debouncing vào input handler của JSON formatter (trễ 300ms). INP trên JSON formatter: 260ms -> 60ms
-
Chuyển tính toán hash generator sang Web Worker. Hash SHA-256 input lớn giờ xảy ra hoàn toàn ngoài main thread. INP trên hash generator: 200ms -> 40ms
Kết Quả#
Sau hai tuần tối ưu (dữ liệu CrUX, mobile, phân vị 75):
- LCP: 3.8s -> 1.8s (cải thiện 53%)
- CLS: 0.12 -> 0.00 (cải thiện 100%)
- INP: 340ms -> 110ms (cải thiện 68%)
Điểm Lighthouse: 62 -> 97.
Cả ba chỉ số vững vàng trong vùng "Tốt". Trang cảm giác tức thì trên mobile. Và traffic tìm kiếm organic tăng 12% trong tháng sau cải thiện (dù tôi không thể chứng minh nhân quả — có các yếu tố khác).
Danh Sách Kiểm Tra#
Nếu bạn không lấy gì khác từ bài viết này, đây là danh sách kiểm tra tôi chạy qua trên mỗi dự án:
LCP#
- Xác định phần tử LCP với DevTools
- Thêm
priority(hoặcfetchPriority="high") vào hình ảnh LCP - Preload tài nguyên LCP trong
<head> - Loại bỏ CSS chặn render
- Tự host font với
next/font - Bật nén Brotli/Gzip
- Dùng static generation hoặc ISR khi có thể
- Đặt header cache mạnh cho asset tĩnh
CLS#
- Tất cả hình ảnh có
widthvàheightrõ ràng - Dùng
next/fontvới font dự phòng điều chỉnh kích thước - Nội dung động dùng
position: fixed/absolutehoặc không gian dành sẵn - Skeleton screen khớp kích thước component thật
- Không chèn nội dung đầu trang sau khi tải
INP#
- Không có task dài (>50ms) trong interaction handler
- Cập nhật state không khẩn cấp bọc trong
startTransition - Input handler được debounce (300ms)
- Tính toán nặng chuyển sang Web Worker
- Kích thước DOM dưới 1,500 node khi có thể
Chung#
- Script bên thứ ba tải sau khi trang interactive
- Kích thước bundle được phân tích và tree-shaken
- CSS không dùng đã xóa
- Hình ảnh phục vụ định dạng AVIF/WebP
- Giám sát người dùng thật trong production (thư viện web-vitals)
Suy Nghĩ Cuối Cùng#
Tối ưu hiệu suất không phải nhiệm vụ một lần. Nó là kỷ luật. Mỗi tính năng mới, mỗi dependency mới, mỗi script bên thứ ba mới là một lần thoái lui tiềm tàng. Các trang duy trì tốc độ là những trang có ai đó đang theo dõi metric liên tục, không phải những trang có ai đó làm sprint tối ưu một lần.
Thiết lập giám sát người dùng thật. Thiết lập cảnh báo khi metric thoái lui. Đưa hiệu suất vào quy trình review code. Khi ai đó thêm thư viện 200KB, hỏi có giải pháp thay thế 5KB không. Khi ai đó thêm tính toán đồng bộ trong event handler, hỏi có thể defer hoặc chuyển sang worker không.
Các kỹ thuật trong bài viết này không phải lý thuyết. Chúng là những gì tôi thực sự đã làm, trên trang này, với con số thực để chứng minh. Trải nghiệm của bạn sẽ khác — mỗi trang khác nhau, mỗi đối tượng khác nhau, mỗi hạ tầng khác nhau. Nhưng nguyên tắc là phổ quát: tải ít hơn, tải thông minh hơn, đừng chặn main thread.
Người dùng sẽ không gửi thư cảm ơn cho trang nhanh. Nhưng họ sẽ ở lại. Họ sẽ quay lại. Và Google sẽ nhận thấy.