Đang tải...

Ếch Trendy

Cùng chú ếch nhỏ khám phá thế giới Web đầy biến động. Cập nhật trending nhanh như cách ếch đớp mồi! ⌨️🌿


0

Tối ưu Core Web Vitals 2026: Giảm LCP, INP, CLS cho website thực chiến

Ếch Trendy
Ếch Trendy

2 tháng trước · 10 phút đọc

Tại sao Core Web Vitals quan trọng hơn bao giờ hết

Mình từng bỏ qua phần "Performance" trong Lighthouse suốt 2 năm. Điểm 45/100 nhưng app vẫn chạy được, khách hàng không phàn nàn nhiều. Cho đến khi SEO ranking tụt 3 trang và bounce rate nhảy lên 68% - lúc đó mình mới ngồi đọc lại docs của Google.

Core Web Vitals không chỉ là điểm số đẹp để show với sếp. Từ năm 2021, Google dùng nó như một ranking factor trực tiếp. Năm 2024, INP (Interaction to Next Paint) chính thức thay thế FID, và năm 2026 trọng số của cả ba chỉ số đang được nâng lên trong thuật toán tìm kiếm.

Ba chỉ số cần quan tâm:

  • LCP (Largest Contentful Paint): Thời gian render phần tử lớn nhất - ảnh hero, heading chính. Mục tiêu: dưới 2.5s
  • INP (Interaction to Next Paint): Thời gian phản hồi khi người dùng click/tap/type. Mục tiêu: dưới 200ms
  • CLS (Cumulative Layout Shift): Mức độ giật layout khi tải trang. Mục tiêu: dưới 0.1

346118 Ba chỉ số - ba vấn đề khác nhau, cần fix theo từng hướng riêng

Google có một thống kê khá thú vị: Bounce probability rises 32% as load time increases from 1 second to 3 seconds (Google research and industry statistics). Con số này đủ để justify việc bỏ thời gian tối ưu.

Trước khi đi vào fix cụ thể, bước quan trọng nhất là đo đúng. Dùng PageSpeed Insights để lấy lab data (môi trường kiểm soát) và Search Console để xem field data (dữ liệu thực từ người dùng thật). Hai nguồn này thường khác nhau đáng kể - field data mới là thứ Google dùng để rank.


Fix LCP: Làm cho trang tải nhanh hơn một cách nhìn thấy được

LCP chậm thường đến từ 3 nguồn chính: ảnh chưa tối ưu, server response chậm, và render-blocking resources. Mình sẽ đi theo thứ tự impact từ cao xuống thấp.

Tối ưu ảnh - fix có impact lớn nhất

Đây thường là thủ phạm số 1. Ảnh hero 2MB JPEG load trên 3G? LCP của bạn sẽ tệ ngay.

<!-- ❌ Cách cũ - JPEG nặng, không responsive -->
<img src="hero.jpg" alt="Hero image">

<!-- ✅ Cách đúng - WebP với fallback, đúng kích thước -->
<picture>
  <!-- AVIF cho browser hỗ trợ (Chrome 85+, Firefox 93+) -->
  <source srcset="hero.avif" type="image/avif">
  <!-- WebP fallback -->
  <source srcset="hero-800.webp 800w, hero-1200.webp 1200w, hero-1600.webp 1600w"
          type="image/webp"
          sizes="(max-width: 768px) 100vw, 50vw">
  <!-- PNG/JPEG fallback cho IE -->
  <img src="hero.jpg" alt="Hero image"
       width="1200" height="600"
       fetchpriority="high"
       loading="eager">
</picture>

Hai điểm quan trọng hay bị bỏ sót:

  • fetchpriority="high" trên LCP element: báo cho browser biết fetch ảnh này trước. Không có attribute này, browser có thể defer nó.
  • widthheight cụ thể: giúp browser tính trước layout space, giảm CLS đồng thời.

346119 Thiếu fetchpriority trên ảnh hero là lý do LCP cao hơn 0.5-1s so với cần thiết

Preload LCP resource

Nếu LCP element là ảnh background CSS hoặc font, browser không biết trước để fetch sớm:

<!-- Thêm vào <head>, trước các script khác -->
<link rel="preload" as="image" href="/hero.avif"
      imagesrcset="/hero-800.avif 800w, /hero-1600.avif 1600w"
      imagesizes="100vw"
      fetchpriority="high">

Server response time (TTFB)

Nếu server trả về HTML chậm hơn 600ms, LCP gần như chắc chắn fail. Một vài fix phổ biến:

# Thêm vào nginx config - bật compression
gzip on;
gzip_types text/html text/css application/javascript image/svg+xml;
gzip_min_length 1000;

# Cache static assets
location ~* \.(js|css|png|jpg|webp|avif|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

Với Next.js hoặc các SSR framework, đảm bảo bạn đang dùng ISR (Incremental Static Regeneration) thay vì SSR thuần cho các trang không thay đổi thường xuyên. TTFB của ISR thường dưới 100ms so với SSR có thể lên 500ms+.


Fix INP: Làm cho trang phản hồi click trong 200ms

INP là chỉ số khó fix nhất vì nó đo cái mà người dùng cảm nhận trực tiếp khi tương tác. Click một button mà phải đợi 500ms mới thấy phản hồi? INP của bạn đang fail.

Vấn đề thường đến từ Long Tasks - những đoạn JavaScript chạy trên main thread hơn 50ms liên tục, block mọi tương tác trong thời gian đó.

Tìm Long Tasks với Chrome DevTools

Mở DevTools → Performance tab → Record khi interact với trang. Những task màu đỏ/vàng dài hơn 50ms là thủ phạm.

// Dùng PerformanceObserver để monitor Long Tasks trong production
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // Log task nào chạy lâu hơn 100ms
    if (entry.duration > 100) {
      console.warn('Long Task detected:', {
        duration: entry.duration,
        startTime: entry.startTime,
        // attribution giúp biết task từ đâu
        attribution: entry.attribution
      });
    }
  }
});

observer.observe({ entryTypes: ['longtask'] });

346120 Long Tasks màu đỏ trong Performance tab - đây là nơi bắt đầu debug INP

Tách nhỏ Long Tasks

Thay vì chạy một đoạn code lớn một lần, chia nhỏ và yield về cho main thread:

// ❌ Cách này block main thread ~200ms
function processLargeList(items) {
  return items.map(item => heavyTransform(item));
}

// ✅ Yield sau mỗi chunk - main thread vẫn nhận được input
async function processLargeListAsync(items) {
  const results = [];
  const CHUNK_SIZE = 50; // Xử lý 50 items mỗi lần

  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    results.push(...chunk.map(item => heavyTransform(item)));

    // Yield về main thread sau mỗi chunk
    await scheduler.yield(); // Chrome 115+ / dùng setTimeout(0) cho fallback
  }

  return results;
}

// Fallback cho browser chưa hỗ trợ scheduler.yield()
function yieldToMain() {
  return new Promise(resolve => setTimeout(resolve, 0));
}

Debounce input handlers

Event handlers chạy trên mỗi keystroke mà không có debounce là nguyên nhân phổ biến với search box và form validation:

// ❌ Gọi API mỗi lần gõ phím
input.addEventListener('input', (e) => {
  fetchSearchResults(e.target.value); // Block INP
});

// ✅ Debounce 300ms - đủ để người dùng gõ xong
function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

input.addEventListener('input', debounce((e) => {
  fetchSearchResults(e.target.value);
}, 300));

Với React, đừng để heavy computation trong render cycle. Dùng useMemouseCallback đúng chỗ - không phải wrap tất cả, mà wrap những gì thật sự expensive. Nếu muốn hiểu sâu hơn về JavaScript performance và cách browser xử lý event loop, khóa Lập Trình JavaScript Nâng Cao có section riêng về async và performance khá chi tiết.


Fix CLS: Chấm dứt layout nhảy lung tung

CLS tệ thường xảy ra đột ngột và khó reproduce - người dùng đang đọc thì layout shift, click nhầm vào quảng cáo thay vì button. Khó chịu, và Google phạt nặng.

Ba nguyên nhân phổ biến nhất:

1. Ảnh không có kích thước

Browser không biết ảnh to bao nhiêu trước khi tải xong, nên không reserve space:

<!-- ❌ Không width/height - layout shift khi ảnh load xong -->
<img src="product.webp" alt="Sản phẩm">

<!-- ✅ Luôn khai báo width và height thực tế -->
<img src="product.webp" alt="Sản phẩm" width="400" height="300">

<!-- ✅ Hoặc dùng CSS aspect-ratio -->
<style>
.product-image {
  aspect-ratio: 4 / 3; /* Reserve đúng tỉ lệ ảnh */
  width: 100%;
  height: auto;
}
</style>

2. Font chữ thay thế (FOUT)

Browser load fallback font trước, sau đó swap sang custom font làm text nhảy vị trí:

/* font-display: swap là mặc định của Google Fonts - gây CLS */
@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');
  /* optional: chỉ dùng custom font nếu load trong 100ms
     Nếu không kịp, dùng fallback vĩnh viễn - không swap nữa */
  font-display: optional;
}

/* Hoặc dùng size-adjust để fallback font khớp kích thước hơn */
@font-face {
  font-family: 'FallbackFont';
  src: local('Arial');
  /* Điều chỉnh Arial để gần giống Roboto hơn */
  size-adjust: 100.06%;
  ascent-override: 95%;
}

346121 font-display: optional loại bỏ layout shift hoàn toàn, trade-off là font tải chậm có thể không hiển thị

3. Nội dung inject động (ads, banners)

// ❌ Inject banner vào đầu trang sau khi render
document.querySelector('.header').insertBefore(banner, firstChild);

// ✅ Reserve space trước cho phần tử sẽ xuất hiện
/* Reserve space ngay cả khi ad chưa load */
.ad-container {
  min-height: 90px; /* Chiều cao chuẩn của banner quảng cáo */
  width: 728px;
  background: #f5f5f5; /* Placeholder màu nhạt */
}

Với các component lazy load trong React, dùng Suspense với fallback có cùng kích thước:

// ✅ Fallback skeleton cùng kích thước component thật
<Suspense fallback={
  <div style={{ height: '200px', background: '#eee', borderRadius: '8px' }} />
}>
  <HeavyComponent />
</Suspense>

Đo đúng với Lighthouse và PageSpeed Insights

Mình thấy nhiều bạn chỉ chạy Lighthouse một lần rồi cố fix để điểm lên 90+. Thực ra không phải thế.

Lab data vs Field data - đây là điểm quan trọng nhất cần hiểu.

Lighthouse (lab data) đo trong môi trường giả lập: CPU throttle 4x, mạng 3G Fast. Hữu ích để debug nhưng không phản ánh trải nghiệm người dùng thực. Field data từ Chrome UX Report (CrUX) mới là thứ Google dùng để rank - và nó lấy từ người dùng Chrome thật, với device và mạng thật của họ.

# Dùng Lighthouse CLI để automation trong CI/CD
npm install -g lighthouse

# Chạy với device mobile (quan trọng hơn desktop với Google)
lighthouse https://your-site.com \
  --preset=perf \
  --form-factor=mobile \
  --throttling-method=simulate \
  --output=json \
  --output-path=./lighthouse-report.json

# Parse kết quả để lấy 3 chỉ số chính
node -e "
const report = require('./lighthouse-report.json');
const { lcp, inp, cls } = report.audits;
console.log('LCP:', lcp.numericValue / 1000, 's');
console.log('CLS:', cls.numericValue);
"

346122 Lab data vs Field data thường chênh nhau 30-50% - đừng chỉ nhìn điểm Lighthouse

Thêm monitoring vào production

Dùng web-vitals library để lấy field data ngay từ codebase:

import { onLCP, onINP, onCLS } from 'web-vitals';

// Gửi metrics về analytics của bạn
function sendToAnalytics(metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
    id: metric.id,
    page: window.location.pathname
  });

  // Dùng sendBeacon để không block trang
  navigator.sendBeacon('/analytics', body);
}

onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);

Package web-vitals nhỏ gọn (~1.5KB), không ảnh hưởng performance. Bạn có thể gửi data về Google Analytics 4 hoặc bất kỳ endpoint nào. Điểm mạnh là nó đo real user experience thay vì lab environment.


Case study thực tế: Website thương mại điện tử Việt

Mình sẽ chia sẻ một case mình tham gia tối ưu - website bán hàng với ~50k pageviews/tháng, stack Next.js + Vercel.

Baseline trước khi optimize (field data từ Search Console):

  • LCP: 4.8s (Kém)
  • INP: 380ms (Kém)
  • CLS: 0.28 (Kém)

Cả ba đều đỏ. Điểm Lighthouse mobile: 38/100.

346123 Từ điểm 38 lên 84 - không magic, chỉ là làm đúng thứ tự ưu tiên

Những gì đã làm (theo thứ tự impact):

1. Fix LCP - 2 ngày Ảnh hero 1.8MB JPEG → chuyển sang AVIF với srcset. Thêm fetchpriority="high"<link rel="preload">. LCP giảm từ 4.8s → 2.1s.

2. Fix CLS - nửa ngày Thêm widthheight cho tất cả <img>. Đổi banner ads từ inject sau render sang pre-reserved container. CLS giảm từ 0.28 → 0.04.

3. Fix INP - 3 ngày (phức tạp nhất) Phát hiện một component filter sản phẩm có Long Task 450ms mỗi lần click. Nguyên nhân: re-render toàn bộ 200 product cards. Fix bằng React.memo + virtualization với react-window. INP giảm từ 380ms → 145ms.

Kết quả sau 6 tuần (đợi Google crawl lại và cập nhật CrUX):

  • LCP: 2.1s → Tốt
  • INP: 145ms → Tốt
  • CLS: 0.04 → Tốt
  • Lighthouse mobile: 38 → 84
  • Organic traffic: tăng 31% organic search traffic growth over 90 days after optimizing Core Web Vitals to 'Good' (Shopify Plus e-commerce case study) (Shopify Plus E-commerce Case Study) so với 3 tháng trước

Điều mình học được: thứ tự ưu tiên quan trọng hơn làm tất cả cùng lúc. LCP ảnh hưởng ngay đến cảm nhận đầu tiên, fix trước. CLS nhanh fix hơn INP nhiều, làm tiếp. INP để cuối vì cần profiling kỹ.

Tải checklist đầy đủ ở đây: Tải checklist Core Web Vitals 2026