Biểu đồ LCP giảm dần qua các giai đoạn tối ưu
Web Performance Deep Dive

Giảm 60% LCP — Những gì tôi học được từ hệ thống booking airline

Khi LCP của trang booking chính nằm ở 4.2 giây trên mobile, bạn không thể nói với PM rằng "performance cũng ổn mà." Con số đó nghĩa là cứ 10 người mở trang, 3 người đã bỏ đi trước khi thấy nội dung. Bài này chia sẻ từng bước tôi đã làm để kéo LCP xuống 1.7 giây — không có magic bullet, chỉ có hàng loạt cải tiến nhỏ cộng dồn lại.

Hiểu LCP thực sự đo gì

Trước khi optimize, cần hiểu LCP element trên trang là gì. Nhiều người nghĩ LCP luôn là hero image — sai. LCP element là phần tử lớn nhất trong viewport tại thời điểm render, có thể là <img>, <video>, element có background-image, hoặc block-level text element.

Trên trang booking của chúng tôi, LCP element là một text block — cái search form header. Nghĩa là vấn đề không phải image mà là render-blocking resources ngăn text hiển thị.

// Dùng PerformanceObserver để xác định LCP element
new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];
  console.log('LCP element:', lastEntry.element);
  console.log('LCP time:', lastEntry.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });

💡 Luôn đo trước, optimize sau. Đừng đoán LCP element là gì — dùng DevTools hoặc PerformanceObserver để xác nhận.

Giai đoạn 1: Dọn dẹp critical path

Bước đầu tiên không phải thêm gì mới, mà là bỏ những thứ đang chặn render.

Audit render-blocking resources

Chrome DevTools → Performance panel cho thấy 3 thủ phạm chính: 2 file CSS không critical nằm trong <head> mà không có media query, 1 third-party script analytics load synchronous.

Giải pháp đơn giản nhưng hiệu quả:

<!-- Trước: CSS non-critical block render -->
<link rel="stylesheet" href="/css/animations.css">
<link rel="stylesheet" href="/css/footer.css">

<!-- Sau: defer non-critical CSS -->
<link rel="stylesheet" href="/css/animations.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="/css/footer.css" media="print" onload="this.media='all'">

Analytics script chuyển sang async — đơn giản nhưng tiết kiệm 400ms trên slow 3G.

Font subsetting

Font tiếng Việt thường nặng vì bao gồm toàn bộ bảng mã Unicode. File NotoSans-Regular.woff2 gốc nặng 180KB. Sau khi subset chỉ giữ Latin + Vietnamese diacritics, còn 28KB.

# Dùng pyftsubset (fonttools) để subset
pyftsubset NotoSans-Regular.ttf \
  --output-file=NotoSans-Regular-subset.woff2 \
  --flavor=woff2 \
  --layout-features='kern,liga' \
  --unicodes='U+0000-00FF,U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+1EA0-1EF9'

Kết hợp với font-display: swap và preload font file:

<link rel="preload" href="/fonts/NotoSans-Regular-subset.woff2"
      as="font" type="font/woff2" crossorigin>

⚠️ crossorigin attribute bắt buộc khi preload font, kể cả font self-hosted. Thiếu nó thì browser sẽ fetch font 2 lần.

Giai đoạn 2: Resource hints đúng chỗ

Resource hints mạnh nhưng dùng sai thì phản tác dụng — preload quá nhiều resource sẽ tranh bandwidth với LCP element.

Preconnect cho third-party origins

<!-- API server cho search form -->
<link rel="preconnect" href="https://api.booking.example.com">
<link rel="dns-prefetch" href="https://api.booking.example.com">

<!-- CDN cho assets -->
<link rel="preconnect" href="https://cdn.booking.example.com">

Tại sao dùng cả preconnect lẫn dns-prefetch? Vì preconnect không được support trên một số browser cũ, dns-prefetch là fallback.

Preload chỉ những gì thực sự critical

Nguyên tắc: chỉ preload resource nằm trên critical rendering path mà browser chưa thể discover sớm. Ví dụ font file (browser chỉ discover khi parse CSS) thì preload hợp lý. Còn ảnh nằm ngay trong HTML thì browser đã discover rồi — preload thêm chỉ lãng phí bandwidth priority.

<!-- ✅ Preload font — browser discover muộn -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>

<!-- ❌ Đừng preload ảnh đã nằm trong HTML -->
<!-- <link rel="preload" href="/hero.webp" as="image"> -->
<!-- Browser đã thấy <img src="/hero.webp"> rồi -->

Giai đoạn 3: Streaming SSR thay vì chờ full render

Đây là bước tạo ra sự khác biệt lớn nhất. Trước đó, server phải render toàn bộ HTML xong mới gửi response. User chờ 1.2 giây trước khi nhận được byte đầu tiên (TTFB).

Chuyển sang streaming SSR với React 18 renderToPipeableStream:

import { renderToPipeableStream } from 'react-dom/server';

app.get('*', (req, res) => {
  const { pipe } = renderToPipeableStream(<App url={req.url} />, {
    bootstrapScripts: ['/client.js'],
    onShellReady() {
      // Shell (layout + above-the-fold) gửi ngay
      res.setHeader('Content-Type', 'text/html');
      pipe(res);
    },
    onError(error) {
      console.error(error);
      res.statusCode = 500;
    },
  });
});

Kết hợp <Suspense> để defer phần dưới fold:

function BookingPage() {
  return (
    <Layout>
      {/* Shell — gửi ngay */}
      <SearchForm />

      {/* Defer — stream sau */}
      <Suspense fallback={<FlightResultsSkeleton />}>
        <FlightResults />
      </Suspense>

      <Suspense fallback={<ReviewsSkeleton />}>
        <UserReviews />
      </Suspense>
    </Layout>
  );
}

TTFB giảm từ 1.2 giây xuống 200ms. User thấy search form gần như ngay lập tức, flight results stream vào sau.

Giai đoạn 4: Image optimization cuối cùng

Sau khi fix xong text LCP, một số trang có LCP element là image. Cho những trang đó:

<!-- Responsive images với format hiện đại -->
<picture>
  <source srcset="/img/promo-400.avif 400w, /img/promo-800.avif 800w"
          type="image/avif">
  <source srcset="/img/promo-400.webp 400w, /img/promo-800.webp 800w"
          type="image/webp">
  <img src="/img/promo-800.jpg"
       alt="Khuyến mãi vé máy bay"
       width="800" height="400"
       loading="eager"
       fetchpriority="high"
       decoding="async">
</picture>

💡 fetchpriority="high" trên LCP image rất quan trọng. Nó báo browser ưu tiên fetch ảnh này trước các resource khác. Chỉ dùng cho 1-2 ảnh above-the-fold, đừng spam.

Kết quả tổng hợp

Từng bước một, kết quả cộng dồn:

Cải tiến LCP giảm
Defer non-critical CSS + async scripts -600ms
Font subsetting + preload -400ms
Resource hints (preconnect) -200ms
Streaming SSR -800ms
Image optimization (cho image LCP pages) -500ms

Tổng: từ 4.2s xuống 1.7s trên mobile (p75).

Điều tôi rút ra lớn nhất: không có "one trick" nào giải quyết LCP. Đó là tổng hợp của hàng chục quyết định đúng — mỗi cái tiết kiệm vài trăm milliseconds. Và quan trọng nhất, phải đo liên tục. Không đo thì không biết đang ở đâu.


Có câu hỏi hay góp ý? Reach out trên Twitter hoặc GitHub.