Đ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

Core Web Vitals 2026: Tối ưu INP, LCP, CLS để tăng SEO và conversion rate

Ếch Trendy
Ếch Trendy

một tháng trước · 8 phút đọc

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

Mình nhớ hồi 2021, khi Google thông báo Core Web Vitals sẽ là ranking signal - cộng đồng dev phản ứng kiểu "ừ thì biết rồi, tối ưu sau". Rồi sau là mãi mãi không làm.

Đến 2024, Google chính thức thay thế FID bằng INP (Interaction to Next Paint). Đến 2026, ba chỉ số LCP, INP, CLS không chỉ ảnh hưởng đến ranking - chúng ảnh hưởng trực tiếp đến conversion rate. 32% increase in bounce probability when mobile page load time increases from 1 second to 3 seconds (Google Think with Google Mobile Speed Study) cho thấy mỗi giây trễ thêm trong load time tương quan với tỷ lệ bounce tăng đáng kể.

Nếu bạn đang build web app với React, Vue, hay bất kỳ framework nào - bài này là checklist thực chiến bạn cần trước khi deploy production.

346911 Ba chỉ số - ba vấn đề khác nhau, cần cách xử lý khác nhau

Mục tiêu cụ thể Google đang dùng để phân loại:

  • LCP (Largest Contentful Paint): ≤ 2.5s là tốt, > 4s là kém
  • INP (Interaction to Next Paint): ≤ 200ms là tốt, > 500ms là kém
  • CLS (Cumulative Layout Shift): ≤ 0.1 là tốt, > 0.25 là kém

Đo lường trước, tối ưu sau

Tối ưu mà không có số liệu thì cũng như đi trong tối. Bước đầu tiên luôn là đo.

PageSpeed Insights

Truy cập PageSpeed Insights, nhập URL và bạn sẽ thấy điểm Core Web Vitals từ cả lab data (Lighthouse) lẫn field data (real users). Hai loại này khác nhau - lab data chạy trong môi trường kiểm soát, field data là số liệu thực từ người dùng Chrome.

Field data quan trọng hơn vì Google dùng nó để rank.

Web Vitals library trong code

Thêm thư viện web-vitals vào project để đo real user metrics (RUM) và gửi về analytics:

// Cài đặt: npm install web-vitals
import { onLCP, onINP, onCLS } from 'web-vitals';

// Gửi metrics về analytics hoặc backend
function sendToAnalytics({ name, value, rating }) {
  // rating: 'good' | 'needs-improvement' | 'poor'
  console.log(`${name}: ${value} (${rating})`);
  
  // Ví dụ gửi về Google Analytics 4
  gtag('event', name, {
    value: Math.round(name === 'CLS' ? value * 1000 : value),
    metric_rating: rating,
  });
}

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

346912 Field data từ real users quan trọng hơn lab score - đừng chỉ nhìn Lighthouse

Chrome DevTools

Mở DevTools → tab Performance → Record khi tương tác. Bạn sẽ thấy chính xác đoạn JavaScript nào block main thread lâu nhất. Tìm những đoạn màu đỏ trong flame chart - đó là long tasks cần xử lý.

Search Console

Google Search Console có tab Core Web Vitals riêng, phân loại URL theo nhóm "Kém/Cần cải thiện/Tốt". Đây là nguồn dữ liệu field data miễn phí, trực tiếp từ Google - check hàng tuần là hợp lý.


Tối ưu LCP - Largest Contentful Paint

LCP đo thời gian để phần tử lớn nhất (thường là hero image hoặc heading lớn) xuất hiện trong viewport. Kém LCP thường do 3 nguyên nhân chính: server response chậm, resource load chậm, hoặc render-blocking resources.

Tối ưu hình ảnh hero

Đây là nguyên nhân số 1. Ảnh hero thường là phần tử LCP - load chậm là điểm LCP tệ ngay.

<!-- ĐÚNG: preload ảnh hero, dùng fetchpriority -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">

<!-- ĐÚNG: thẻ img với kích thước rõ ràng, không lazy load ảnh LCP -->
<img 
  src="/hero.webp" 
  width="1200" 
  height="630"
  fetchpriority="high"
  alt="Hero image"
>
<!-- SAI: đừng thêm loading="lazy" cho ảnh LCP -->

Chuyển sang định dạng WebP hoặc AVIF - AVIF nhỏ hơn JPEG khoảng AVIF is typically 45-50% smaller than JPEG at comparable quality for web images (Web.dev and independent web image benchmarks (2023-2024)) trong khi chất lượng tương đương. Nếu dùng Next.js, component <Image> làm điều này tự động.

Tối ưu server response (TTFB)

LCP không thể tốt nếu server response (TTFB) chậm. Một số cách nhanh:

// Ví dụ với Express: thêm Cache-Control header
app.get('/api/data', (req, res) => {
  // Cache 5 phút ở CDN, 1 phút ở browser
  res.set('Cache-Control', 'public, max-age=60, s-maxage=300');
  res.json(data);
});
  • Dùng CDN để serve static assets gần người dùng hơn
  • Enable HTTP/2 hoặc HTTP/3 trên server
  • Implement server-side caching (Redis) cho data thường xuyên truy cập

Font loading

<!-- Preconnect tới Google Fonts trước -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

<!-- Thêm font-display: swap để tránh FOIT (Flash of Invisible Text) -->
<style>
  @font-face {
    font-family: 'MyFont';
    src: url('/fonts/myfont.woff2') format('woff2');
    font-display: swap; /* Hiện fallback font ngay, swap sau khi load xong */
  }
</style>

346913 Preload ảnh hero + WebP/AVIF là combo đơn giản nhất để cải thiện LCP nhanh


Tối ưu INP - Interaction to Next Paint

INP là chỉ số mới nhất và cũng khó tối ưu nhất. Nó đo thời gian từ khi người dùng tương tác (click, keypress, tap) đến khi browser render frame tiếp theo. Khác với FID chỉ đo lần tương tác đầu tiên, INP đo tất cả các tương tác trong suốt session.

Vấn đề ở đâu? Long tasks trên main thread.

Break up long tasks

Bất kỳ task nào chạy quá 50ms đều block main thread và làm INP tệ. Cách fix là chia nhỏ:

// TRƯỚC: một task dài block main thread
function processLargeList(items) {
  items.forEach(item => {
    // Xử lý nặng - giả sử mất 200ms tổng
    heavyComputation(item);
  });
}

// SAU: dùng scheduler để yield control về cho browser
async function processLargeListOptimized(items) {
  for (let i = 0; i < items.length; i++) {
    heavyComputation(items[i]);
    
    // Yield sau mỗi 50 items để browser có thể xử lý input
    if (i % 50 === 0) {
      await scheduler.yield(); // Chrome 115+
      // Fallback cho browser cũ:
      // await new Promise(r => setTimeout(r, 0));
    }
  }
}

Tối ưu event handlers

// TRƯỚC: xử lý nặng trực tiếp trong event handler
button.addEventListener('click', () => {
  // Logic nặng chạy ngay -> INP tệ
  const result = expensiveOperation();
  updateUI(result);
});

// SAU: tách UI update ra khỏi heavy computation
button.addEventListener('click', () => {
  // Cập nhật UI ngay lập tức (visual feedback)
  button.disabled = true;
  showLoadingState();
  
  // Defer heavy work
  setTimeout(() => {
    const result = expensiveOperation();
    updateUI(result);
    button.disabled = false;
  }, 0);
});

346914 Yield control về cho browser sau mỗi chunk - người dùng không bao giờ cảm thấy bị block

Code splitting với dynamic import

Đừng load toàn bộ JavaScript ngay từ đầu. Dùng dynamic import để chỉ load code khi cần:

// Trong React: lazy load component nặng
import { lazy, Suspense } from 'react';

// Component chỉ được load khi cần render
const HeavyChart = lazy(() => import('./HeavyChart'));

function Dashboard() {
  return (
    <Suspense fallback={<div>Đang tải...</div>}>
      <HeavyChart />
    </Suspense>
  );
}

Nếu bạn muốn đi sâu hơn vào JavaScript async patterns và event loop - khóa Lập Trình JavaScript Nâng Cao có phần cover closure, microtask queue khá chi tiết, giúp hiểu tại sao setTimeout(r, 0) lại hoạt động như vậy.


Tối ưu CLS - Cumulative Layout Shift

CLS đo tổng lượng layout shift không mong đợi. Bạn đã từng vào web, chuẩn bị click một nút thì nó nhảy xuống vì ảnh load xong? Đó là CLS.

CLS thường bị gây ra bởi: ảnh không có kích thước, ads/embeds chèn vào sau khi render, và font swap.

Luôn đặt width và height cho ảnh

<!-- SAI: không có kích thước -> browser không biết reserve space
     -> ảnh load xong -> layout shift -->
<img src="photo.jpg" alt="Photo">

<!-- ĐÚNG: browser reserve đúng không gian trước khi ảnh load xong -->
<img src="photo.jpg" width="800" height="600" alt="Photo">

<!-- Với CSS responsive, thêm vào stylesheet -->
<style>
  img {
    max-width: 100%;
    height: auto; /* Giữ aspect ratio từ HTML attributes */
  }
</style>

Skeleton loading thay vì không có gì

// Trong React: hiện skeleton trong khi data load
function UserCard({ userId }) {
  const { data, loading } = useUser(userId);
  
  if (loading) {
    // Skeleton giữ nguyên kích thước -> không có layout shift
    return (
      <div className="user-card skeleton" style={{ height: '120px' }}>
        <div className="skeleton-avatar" />
        <div className="skeleton-text" />
      </div>
    );
  }
  
  return <div className="user-card">{/* nội dung thật */}</div>;
}

346915 Skeleton giữ đúng kích thước trước khi content load - CLS = 0

Tránh layout shift do font

font-display: swap giải quyết FOIT nhưng lại tạo ra layout shift nhỏ khi font swap xảy ra. Giải pháp tốt hơn là dùng font-display: optional với font preload:

@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');
  /* optional: chỉ dùng nếu đã cache, không swap -> CLS = 0 */
  font-display: optional;
}

Lần đầu người dùng truy cập sẽ thấy fallback font, từ lần hai trở đi font custom được dùng ngay. Trade-off: lần đầu tiên không thấy font custom - chấp nhận được với hầu hết use case.

Tải Tải CLS checklist đầy đủ để có checklist đầy đủ tất cả các nguyên nhân gây CLS phổ biến.


Caching, CDN và lazy loading - bộ ba không thể thiếu

Ba kỹ thuật này không giải quyết một chỉ số cụ thể mà cải thiện toàn bộ performance tổng thể.

Lazy loading images

<!-- Ảnh dưới fold: thêm loading="lazy" -->
<!-- QUAN TRỌNG: KHÔNG dùng cho ảnh LCP (hero image) -->
<img 
  src="/product-thumbnail.webp"
  width="400"
  height="300"
  loading="lazy"
  alt="Product"
>

Browser hỗ trợ native lazy loading đã 95%+ browser support for native lazy loading (loading=lazy) on img elements in 2024 (Can I Use / Web Platform Data). Không cần thư viện ngoài cho ảnh thông thường.

Service Worker cho caching

// service-worker.js - cache static assets
const CACHE_NAME = 'v1';
const STATIC_ASSETS = ['/css/main.css', '/js/app.js', '/fonts/myfont.woff2'];

// Precache khi install
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS))
  );
});

// Stale-while-revalidate strategy: trả cache trước, update sau
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).then(async cache => {
      const cached = await cache.match(event.request);
      const fetchPromise = fetch(event.request).then(response => {
        cache.put(event.request, response.clone());
        return response;
      });
      return cached || fetchPromise; // Trả cache ngay nếu có
    })
  );
});

346916 Stale-while-revalidate: người dùng thấy content ngay từ cache, browser tự update ngầm

CDN setup cơ bản

Nếu chưa dùng CDN, đây là điểm tối ưu có ROI cao nhất. Cloudflare free tier là đủ cho hầu hết project:

  1. Trỏ nameserver domain về Cloudflare
  2. Bật "Auto Minify" cho JS/CSS/HTML
  3. Bật "Brotli compression"
  4. Set Cache-Control headers phù hợp từ origin server

Static assets (images, fonts, JS bundles) nên cache ít nhất 1 năm với content-hash trong filename - khi file thay đổi, hash thay đổi, URL mới, cache tự invalidate.

Trang web dùng cookie để hoạt động và đo hiệu quả quảng cáo. Xem chính sách bảo mật