Web Performance Deep Dive

Đừng đoán mò ảnh LCP nữa — fetchpriority và preload đúng cách

Trang landing có hero image 180KB WebP. Lighthouse báo LCP 3.8 giây trên mobile. Team tối ưu ảnh xuống còn 110KB, LCP chỉ cải thiện nhẹ xuống 3.4 giây. Vấn đề không nằm ở byte nữa. Vấn đề là browser phát hiện tài nguyên LCP quá muộn.

Đây là lỗi rất phổ biến: dev nghĩ ảnh LCP chậm vì file to, nhưng thực tế ảnh bị chặn bởi CSS, JavaScript, carousel, lazy-loading, hoặc browser không biết nó quan trọng đến mức nào.

LCP chậm vì browser thấy ảnh quá trễ

Browser chỉ tải sớm khi nó nhìn thấy URL ảnh sớm.

Ví dụ xấu kinh điển:

<div class="hero"></div>
.hero {
  background-image: url('/images/hero.webp');
}

Nếu CSS là render-blocking, browser phải:

  1. tải HTML
  2. phát hiện file CSS
  3. tải CSS
  4. parse CSS
  5. mới biết có background-image

Ảnh hero bị phát hiện muộn hơn nhiều so với:

<img src="/images/hero.webp" alt="Ưu đãi mùa hè">

<img> trong HTML gần đầu document cho browser cơ hội schedule request sớm hơn hẳn.

loading="lazy" trên ảnh hero là tự bắn vào chân

Nhiều codebase bật lazy-load cho mọi ảnh:

<img
  src="/images/hero.webp"
  alt="Ưu đãi mùa hè"
  loading="lazy"
>

Với ảnh dưới fold thì đúng. Với ảnh LCP thì sai.

loading="lazy" nói với browser: "ảnh này chưa cần ngay". Nhưng LCP lại chính là thứ user thấy đầu tiên. Kết quả: browser trì hoãn request, LCP trễ, UX tệ đi.

Rule thực tế:

  • Ảnh có khả năng là LCP: loading="eager" hoặc bỏ hẳn attribute
  • Ảnh dưới fold: loading="lazy"
  • Không áp dụng một rule cho mọi ảnh

fetchpriority="high": tín hiệu rõ ràng cho browser

Khi bạn biết chính xác ảnh nào là ứng viên LCP, hãy nói thẳng:

<img
  src="/images/hero.webp"
  alt="Ưu đãi mùa hè"
  width="1280"
  height="720"
  fetchpriority="high"
>

fetchpriority="high" không ép browser tải ngay bằng mọi giá. Nó chỉ tăng mức ưu tiên tương đối của resource trong scheduler. Đây là khác biệt quan trọng.

Use case tốt:

  • Hero image trên landing page
  • Ảnh cover bài viết nằm trên fold
  • Ảnh sản phẩm chính ở PDP

Use case tệ:

  • Set high cho 5 ảnh đầu trang
  • Set high cho logo, icon, avatar, banner phụ cùng lúc
  • Spam priority đến mức browser không còn biết cái nào thật sự quan trọng

Nếu mọi thứ đều “high priority”, thì không có gì còn high nữa.

preload khi browser không discover được sớm

fetchpriority chỉ giúp sau khi browser đã thấy resource. Còn nếu resource bị phát hiện muộn, bạn phải đưa URL lên sớm hơn bằng preload.

Ví dụ với ảnh hero làm bằng CSS background:

<head>
  <link
    rel="preload"
    as="image"
    href="/images/hero.webp"
    fetchpriority="high"
  >
</head>

Lúc này browser biết URL ảnh từ trong <head>, thay vì đợi parse xong CSS.

Đây là chỗ preload thực sự mạnh:

  • background image quan trọng
  • ảnh được inject muộn bởi JS
  • ảnh LCP nằm trong carousel hoặc component hydrate sau

Nhưng preload dễ bị lạm dụng hơn fetchpriority.

Sai lầm phổ biến: preload nhầm ảnh không phải LCP

Hero desktop và mobile thường khác nhau:

<picture>
  <source media="(min-width: 768px)" srcset="/images/hero-desktop.webp">
  <img src="/images/hero-mobile.webp" alt="Ưu đãi mùa hè">
</picture>

Nếu bạn preload cả hai bừa bãi:

<link rel="preload" as="image" href="/images/hero-desktop.webp">
<link rel="preload" as="image" href="/images/hero-mobile.webp">

Bạn đang đốt băng thông cho ít nhất một request không cần thiết.

Với responsive images, preload cũng phải match đúng media:

<link
  rel="preload"
  as="image"
  href="/images/hero-mobile.webp"
  media="(max-width: 767px)"
>

<link
  rel="preload"
  as="image"
  href="/images/hero-desktop.webp"
  media="(min-width: 768px)"
>

Nếu site dùng srcset/sizes, bạn còn phải đảm bảo candidate preloaded thật sự là candidate browser sẽ chọn.

preload không thay thế được markup đúng

Nhiều team thêm preload rồi giữ nguyên HTML xấu:

<script>
  mountHeroCarousel();
</script>

JS chạy xong mới render ảnh hero.

preload có thể cứu phần network, nhưng không cứu được phần render nếu DOM của LCP element xuất hiện muộn vì hydration hoặc client-side rendering.

Thứ tự ưu tiên đúng:

  1. render LCP element càng sớm càng tốt
  2. cho browser thấy URL càng sớm càng tốt
  3. gắn priority đúng mức

Đừng dùng resource hints để vá kiến trúc render tệ.

Width/height: tránh layout shift cho LCP

Một ảnh tải nhanh nhưng thiếu kích thước vẫn gây UX tệ:

<img
  src="/images/hero.webp"
  alt="Ưu đãi mùa hè"
  width="1280"
  height="720"
  fetchpriority="high"
>

Khai báo width/height giúp browser reserve đúng không gian trước khi ảnh tải xong. Điều này không trực tiếp giảm network time, nhưng giảm layout shift và giúp render pipeline ổn định hơn.

Ảnh LCP gần như luôn nên có:

  • width
  • height
  • decoding="async" nếu phù hợp
  • alt text tử tế

Case thực tế: ảnh LCP bị CSS và slider che mờ

Markup kiểu này xuất hiện rất nhiều:

<section class="hero">
  <div id="slider-root"></div>
</section>
import('./hero-slider').then(({ mount }) => mount('#slider-root'));

Bên trong slider mới render ra ảnh đầu tiên.

Nghe có vẻ modular, nhưng với performance thì đây là self-own:

  • ảnh không có trong HTML ban đầu
  • request ảnh đợi JS chunk
  • JS chunk còn có thể đợi hydration
  • LCP trôi sang giây thứ 3-4 rất dễ

Fix thực tế:

  • render slide đầu tiên bằng HTML server/static
  • giữ ảnh đầu tiên là <img> thật
  • chỉ hydrate controls và animation sau
  • nếu bắt buộc background image, preload nó

Cách chọn giữa fetchprioritypreload

Tình huống fetchpriority preload
Ảnh <img> nằm sẵn trong HTML ✅ nên dùng thường không cần
Ảnh background trong CSS không đủ ✅ nên dùng
Ảnh render muộn bởi JS không đủ có thể cần
Bạn không chắc ảnh nào là LCP chưa nên dùng chưa nên dùng
Muốn tăng priority nhẹ, không ép tải sớm quá mức ✅ phù hợp không phải mục tiêu chính

Nói ngắn gọn:

  • fetchpriority = browser đã thấy resource, hãy ưu tiên nó hơn
  • preload = browser chưa thấy resource, đây là URL cần biết sớm

Đo LCP đúng, đừng tối ưu mù

Trước khi thêm hint, hãy xác định đúng LCP element trong field data hoặc DevTools.

Những câu hỏi phải trả lời:

  • LCP là text hay image?
  • Nếu là image, URL được discover ở HTML, CSS hay JS?
  • Resource bắt đầu request ở giây nào?
  • Có bị lazy-loading, slider, hydration hay font blocking chặn không?

Nếu LCP là heading text mà bạn ngồi preload ảnh hero cả buổi, bạn đang tối ưu sai đối tượng.

Kiến trúc an toàn cho ảnh LCP

Markup tốt mặc định:

<picture>
  <source
    media="(min-width: 768px)"
    srcset="/images/hero-desktop.webp"
  >
  <img
    src="/images/hero-mobile.webp"
    alt="Ưu đãi mùa hè"
    width="1280"
    height="720"
    fetchpriority="high"
  >
</picture>

Khi nào thêm preload:

  • ảnh là background image
  • ảnh xuất hiện muộn ngoài HTML ban đầu
  • waterfall cho thấy request ảnh bắt đầu quá trễ

Khi nào không thêm:

  • ảnh đã là <img> đầu trang, browser discover ngay
  • bạn chỉ “cảm giác” nó quan trọng nhưng chưa đo
  • bạn đã có quá nhiều preloads cạnh tranh bandwidth

LCP tốt không đến từ một attribute thần kỳ. Nó đến từ việc browser nhìn thấy đúng resource đúng lúc, với đúng priority.

Giảm byte vẫn quan trọng. Nhưng sau một ngưỡng nào đó, discovery time mới là thứ giết bạn.