Sửa CLS 2026: RUM + size-adjust cho Layout Stability

Hướng dẫn sửa CLS năm 2026: đo bằng web-vitals RUM với attribution, dùng size-adjust triệt tiêu font swap shift, khóa kích thước ảnh/iframe/ads, và debug thực chiến trong Next.js 15 App Router.

Sửa CLS 2026: RUM & size-adjust Guide

Cập nhật: 01 tháng 07, 2026

Cumulative Layout Shift (CLS) là chỉ số Core Web Vitals đo độ ổn định thị giác của trang web, tính bằng công thức impact fraction × distance fraction cộng dồn trong các session window 1 giây (tối đa 5 giây). Để sửa CLS trong năm 2026, bạn cần đo bằng dữ liệu người dùng thực (RUM) qua thư viện web-vitals, dùng size-adjust cùng ascent-override để triệt tiêu font swap shift, khóa kích thước cho mọi ảnh/iframe/quảng cáo, và dùng attribution build để định danh phần tử gây shift. Ngưỡng "tốt" vẫn giữ ở 0.1 tại phân vị 75.

  • Ngưỡng CLS 2026 không đổi: tốt ≤ 0.1, cần cải thiện 0.1–0.25, kém > 0.25, đo tại phân vị 75 tách riêng mobile và desktop.
  • CLS tính theo session window 1 giây, tối đa 5 giây. Điểm cuối là cửa sổ tệ nhất, không phải tổng toàn trang.
  • Chỉ 11% website preload web font. Đây là nguyên nhân CLS âm thầm nhất, và size-adjust có thể kéo CLS từ 0.15 xuống 0.02.
  • Lighthouse chỉ chụp một lần tải; CrUX và RUM mới là nguồn sự thật vì CLS xảy ra suốt vòng đời trang, kể cả sau tương tác.
  • Rakuten 24 tăng 53.37% doanh thu/khách khi giảm CLS trên trang danh mục. CLS ảnh hưởng trực tiếp tới conversion, không chỉ SEO.
  • bfcache reset CLS về 0; iframe shift không đo được từ JS nhưng vẫn tính trong CrUX. Đó là lý do RUM và CrUX luôn lệch nhau chút ít.

CLS là gì và tại sao vẫn quan trọng năm 2026?

Thật lòng mà nói, trong quá trình tư vấn cho các nhóm thương mại điện tử ở Đông Nam Á, CLS luôn là chỉ số bị hiểu sai nhiều nhất. Mọi người nghĩ nó liên quan tới tốc độ tải, thực ra nó chỉ đo khoảng cách và diện tích mà nội dung nhảy lung tung khi người dùng đang nhìn. Một trang có thể có LCP 4 giây và CLS 0, hoặc LCP 1 giây và CLS 0.4. Chúng đo hai chiều hoàn toàn khác nhau của trải nghiệm.

CLS được tính theo công thức impact fraction × distance fraction. Impact fraction là tỷ lệ viewport bị ảnh hưởng bởi phần tử trước và sau khi shift; distance fraction là quãng đường phần tử di chuyển tính theo chiều lớn hơn của viewport. Một phần tử chiếm 50% viewport dịch xuống 25% chiều cao viewport sẽ tạo ra shift 0.5 × 0.25 = 0.125, đã vượt ngưỡng "tốt".

Sau bốn năm CLS trở thành Core Web Vital, tỷ lệ đạt trong CrUX toàn cầu đã đạt 84% (2026). Đây là chỉ số trưởng thành nhất trong bộ Core Web Vitals: các nguyên nhân được liệt kê rõ, kỹ thuật sửa đã ổn định, và Google gần như không có kế hoạch thay đổi ngưỡng. Nhưng chính vì thế, khi trang của bạn trượt CLS, đối thủ đã đi trước rất xa. CLS cũng là chỉ số duy nhất trong CWV không đo bằng thời gian, nên nhiều developer quen debug performance-timing sẽ lúng túng lúc đầu.

Điều tôi nhấn mạnh với mọi khách hàng: CLS cảm nhận được. Khi nút "Thanh toán" nhảy xuống 40px vì banner cookie vừa mount, ngón tay người dùng đã bấm vào "Xóa giỏ hàng" bên dưới. Không cần biểu đồ nào để giải thích tại sao conversion giảm.

CLS bao nhiêu là tốt? Ngưỡng và phân vị 75

Ngưỡng CLS trong 2026 không đổi so với 2024: ≤ 0.1 là tốt, 0.1–0.25 là cần cải thiện, > 0.25 là kém. Ngưỡng này áp dụng ở phân vị 75 (P75), nghĩa là 75% lượt tải trang của bạn phải đạt ≤ 0.1 thì site mới được coi là "pass Core Web Vitals" cho URL đó.

Điểm nhiều team bỏ qua: mobile và desktop được đánh giá tách riêng. Trang có thể pass trên desktop nhưng fail trên mobile vì viewport nhỏ khiến impact fraction lớn hơn. Cùng một banner 60px sẽ đẩy nhiều nội dung hơn khi màn hình chỉ cao 640px. CrUX cũng phân loại theo effective connection type; kết nối 3G và 4G kém thường có CLS xấu hơn vì tài nguyên bất đồng bộ tới muộn.

Một lỗi phổ biến khác là nhìn giá trị CLS trung bình. Trung bình giấu đuôi phân phối. Tôi luôn khuyên team pull P75, P90 và P99 riêng cho mobile 4G, mobile 3G, desktop, phân loại theo landing page. Bạn sẽ phát hiện một template (thường là trang sản phẩm hoặc article) đóng góp gần như toàn bộ CLS xấu, trong khi trang chủ trông rất đẹp.

Cuối cùng: CLS được đo toàn bộ vòng đời trang, không phải chỉ khi load. Người dùng scroll xuống, lazy image mount, sticky footer xuất hiện, tất cả tính vào CLS. Đây là lý do Lighthouse (chỉ chụp một lần) luôn lạc quan hơn CrUX.

Session window: cách CLS thực sự được tính

Trước 2021, CLS cộng dồn toàn bộ layout shift suốt vòng đời trang. Nghĩa là bạn phải "trả giá" cho cả những shift người dùng chủ động kích hoạt bằng cách scroll và click. Sau nhiều phản hồi, Google đổi sang thuật toán session window:

  • Session window bắt đầu ở shift đầu tiên và kết thúc khi có khoảng lặng 1 giây không có shift nào, hoặc khi window dài quá 5 giây.
  • Điểm CLS cuối cùng của trang là điểm window tệ nhất, không phải tổng.
  • Shift trong 500ms sau tương tác người dùng (hadRecentInput = true) bị loại trừ. Bạn có 500ms sau click để render thay đổi mà không bị phạt.

Hệ quả thực tế: nếu trang của bạn có 3 shift nhỏ 0.03 cách nhau 2 giây, CLS = 0.03 (không phải 0.09). Nhưng 3 shift 0.03 dồn trong vòng 800ms sẽ cho CLS = 0.09. Timing quan trọng ngang biên độ. Nhiều team giảm CLS chỉ bằng cách trì hoãn injected banner thêm 1.2 giây để tách nó khỏi window của font swap.

Có một tinh tế khác: nếu trang được restore từ back/forward cache (bfcache), CLS được reset về 0 vì với người dùng đây là lượt truy cập mới. Điều này cũng có nghĩa nếu bạn không lắng nghe sự kiện pageshow trong RUM, CLS của các phiên bfcache có thể bị ghi nhận sai. Thư viện web-vitals đã xử lý đúng chuyện này, nhưng nếu bạn tự viết observer, hãy nhớ điều đó.

Những nguyên nhân chính gây CLS

Sau khi audit vài trăm site, tôi thấy 90% CLS đến từ năm nguồn sau, xếp theo mức độ phổ biến:

  1. Ảnh và video không có kích thước: Không có width/height attribute hoặc aspect-ratio CSS, trình duyệt để chiều cao = 0 rồi "đẩy" nội dung xuống khi ảnh tải xong.
  2. Web font gây swap shift: Fallback font có metrics khác web font. Khi custom font tải xong, dòng text từ 2 dòng thành 3 dòng, mọi thứ bên dưới dịch chuyển.
  3. Nội dung inject phía trên nội dung hiện có: Cookie banner, notification bar, GDPR modal, native app promotion, mount vào body ở đầu render.
  4. Ads, embeds, iframes không giữ chỗ: Container ad không có min-height, Twitter embed resize sau khi tải, Instagram iframe expand khi phát video.
  5. SPA route transition không reserve space: Skeleton chiều cao khác nội dung thật, hoặc không có skeleton, hoặc dynamic import làm component "sinh ra" tại chỗ.

Có một nguyên nhân ít ai để ý: slow connection. Cùng một trang, người dùng 5G có thể có CLS = 0 vì mọi thứ tải trước khi họ nhìn thấy; người dùng 3G có CLS = 0.3 vì họ đã đọc được nội dung khi ảnh mới tải và đẩy nó xuống. Đây là lý do CLS trong lab luôn tốt hơn CLS field. Lab thường throttle mạng nhưng không throttle CPU đúng cách, và không mô phỏng được thời gian người dùng thực sự đọc trang.

Làm sao đo CLS thực tế bằng RUM?

So, đây là phần tôi dành nhiều thời gian nhất khi tư vấn. Lighthouse là công cụ debug, không phải nguồn sự thật. CrUX (Chrome UX Report) và RUM là hai thứ Google thực sự dùng để đánh giá site của bạn. Cách chuẩn nhất là dùng thư viện web-vitals của Google Chrome team với build attribution:

// npm install web-vitals
import { onCLS } from 'web-vitals/attribution';

onCLS((metric) => {
  // metric.value: điểm CLS hiện tại của window tệ nhất
  // metric.rating: 'good' | 'needs-improvement' | 'poor'
  // metric.attribution.largestShiftTarget: CSS selector của phần tử gây shift lớn nhất
  // metric.attribution.largestShiftValue: điểm shift đơn lẻ
  // metric.attribution.largestShiftSource: element node reference
  // metric.attribution.loadState: trạng thái tải khi shift xảy ra

  navigator.sendBeacon('/rum', JSON.stringify({
    name: 'CLS',
    value: metric.value,
    rating: metric.rating,
    target: metric.attribution.largestShiftTarget,
    loadState: metric.attribution.largestShiftLoadState,
    url: location.pathname,
    connection: navigator.connection?.effectiveType,
    deviceMemory: navigator.deviceMemory,
    id: metric.id,
  }));
}, { reportAllChanges: false });

Điểm mấu chốt là reportAllChanges: false (mặc định), chỉ gửi giá trị cuối khi trang unload hoặc chuyển sang background. Nếu bạn set true, mỗi shift đều được báo cáo và bạn có thể theo dõi tiến trình theo thời gian thực, nhưng lượng dữ liệu sẽ tăng đáng kể.

Trên mobile, sự kiện unload gần như không bao giờ chạy khi user chuyển sang app khác. Vì vậy web-vitals dùng visibilitychange để bắt CLS khi trang vào background, rồi dùng sendBeacon (không bị hủy khi trang đóng). Nếu bạn không dùng thư viện có sẵn, phải tự lo cả hai chuyện này.

Để hoàn thiện bức tranh, kết hợp dữ liệu RUM với tối ưu LCP với fetchpriority và Speculation Rules. LCP và CLS thường tương tác: preload sai một tài nguyên có thể fix LCP nhưng tạo font swap shift muộn hơn.

Sửa CLS do font bằng size-adjust và fallback metrics

Font là silent killer của CLS. Chỉ 11% website preload web font, và trong số còn lại đa số dùng font-display: swap. Swap gây ra FOUT (Flash of Unstyled Text) rồi shift khi font thật tới. Từ 2022, CSS có 4 descriptor mới trên @font-face giải quyết triệt để: size-adjust, ascent-override, descent-override, line-gap-override. Kỹ thuật này gọi là font fallback metrics matching.

/* Web font thật */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2-variations');
  font-display: swap;
  font-weight: 100 900;
}

/* Fallback được tinh chỉnh để "chiếm" đúng chiều cao Inter */
@font-face {
  font-family: 'Inter-fallback';
  src: local('Arial');
  size-adjust: 107.4%;
  ascent-override: 90%;
  descent-override: 22.4%;
  line-gap-override: 0%;
}

body {
  font-family: 'Inter', 'Inter-fallback', sans-serif;
}

Các số trong ví dụ trên đúng cho Inter khớp với Arial. Tính cho font khác bằng công cụ Fallback Font Generator của Screenspan hoặc capsize của Formidable. Ở dự án gần nhất, tôi đã thấy site có CLS = 0.15 chỉ vì font swap; sau khi thêm 4 descriptor và đổi font-family, CLS xuống 0.02 mà không cần thay đổi gì khác.

Ba lưu ý quan trọng khi triển khai:

  1. Không dùng font-display: block vì che text 3 giây khiến LCP tệ. swap cộng fallback metrics là combo tốt nhất; optional chỉ dùng khi bạn OK với việc phần lớn người dùng lần đầu không thấy font đẹp.
  2. Vẫn preload font quan trọng với <link rel="preload" as="font" type="font/woff2" crossorigin> để giảm khoảng thời gian FOUT.
  3. Subset font để giảm bytes. Font 200KB tải chậm sẽ có window swap dài hơn, tăng khả năng CLS xảy ra.

Chi tiết hơn về subsetting và self-hosting, xem bài tối ưu font web 2026.

Sửa CLS cho ảnh, iframe và quảng cáo

Nguyên tắc bất di bất dịch: trình duyệt phải biết chiều cao của mọi phần tử trước khi nó tải xong. Với ảnh:

<!-- Đúng: attribute width/height khớp intrinsic ratio -->
<img
  src="/hero.jpg"
  width="1200"
  height="675"
  alt="Product"
  style="width: 100%; height: auto;"
/>

<!-- Nếu ảnh có nhiều nguồn responsive -->
<img
  srcset="/hero-480.jpg 480w, /hero-800.jpg 800w, /hero-1200.jpg 1200w"
  sizes="(max-width: 600px) 100vw, 800px"
  src="/hero-800.jpg"
  width="1200"
  height="675"
  alt="Product"
/>

Trình duyệt hiện đại dùng width/height để tính aspect-ratio ngầm định trong CSS. Dù sau đó bạn override bằng style="width: 100%; height: auto;", chiều cao vẫn được reserve chính xác. Chi tiết áp dụng cho AVIF/WebP nằm trong bài tối ưu hình ảnh web 2026.

Với iframe và embed: luôn set width, height hoặc bọc container có aspect-ratio:

<div style="aspect-ratio: 16 / 9; width: 100%;">
  <iframe
    src="https://www.youtube.com/embed/xxx"
    style="width: 100%; height: 100%; border: 0;"
    loading="lazy"
    allowfullscreen>
  </iframe>
</div>

Với ad slot (Google AdSense, GAM): reserve min-height bằng chiều cao ad phổ biến nhất cho breakpoint đó (thường 250px cho medium rectangle, 90px cho leaderboard). Nếu ad không fill (unfilled), giữ nguyên khoảng trống, không collapse. Rakuten 24 giảm CLS trên trang danh mục bằng cách reserve ad slot chính xác và ghi nhận tăng 53.37% revenue/khách cho nhóm CLS thấp so với nhóm CLS cao.

Với cookie banner và CMP: đặt ở vị trí không đẩy nội dung, ví dụ fixed overlay ở đáy màn hình, không mount ở đầu body. Nếu bắt buộc phải ở top (yêu cầu pháp lý), reserve chiều cao bằng CSS ngay từ SSR, đừng để JS mount rồi mới có kích thước.

Sửa CLS trong React và Next.js

SPA có ba nguồn CLS đặc thù: hydration mismatch, skeleton sai kích thước, và dynamic import không có placeholder. Trong Next.js 15 App Router, hydration đã ổn định hơn nhưng vẫn cần cẩn thận:

// Next.js: dùng next/image để tự động set width/height và tránh CLS
import Image from 'next/image';

export default function Card() {
  return (
    <Image
      src="/product.jpg"
      alt="Product"
      width={600}
      height={400}
      sizes="(max-width: 768px) 100vw, 600px"
      priority={false} // priority chỉ cho LCP image
    />
  );
}

// Dynamic component với skeleton khớp kích thước
import dynamic from 'next/dynamic';

const HeavyChart = dynamic(() => import('./HeavyChart'), {
  ssr: false,
  loading: () => <div style={{ height: 320, width: '100%' }} />, // KHỚP chiều cao thật
});

Điểm hay quên: skeleton phải khớp đúng chiều cao component thật, không phải ước lượng. Tôi đã thấy team dùng skeleton 200px cho component thật 340px và CLS tăng 0.08 mỗi lần route đổi. Đo trong Chrome DevTools Performance tab, xem Layout Shifts track để biết chính xác.

Với React 19 và Server Components, phần lớn CLS từ client-only fetch biến mất vì nội dung streamed cùng SSR. Nhưng <Suspense> fallback vẫn phải reserve đúng kích thước. Nếu bạn dùng Partial Prerendering (Next.js 15), phần dynamic hole phải có placeholder có kích thước cố định, hoặc chấp nhận CLS nhỏ và bù bằng cách trì hoãn injected banner để tách khỏi window.

Debug CLS bằng attribution và DevTools

Khi RUM báo CLS xấu ở một URL cụ thể, đây là quy trình debug 5 phút tôi luôn dùng:

  1. Chrome DevTools → Performance: record trang load với CPU throttle 4x và mạng "Slow 4G". Xem hàng Experience, mỗi shift là một block đỏ có tooltip hiển thị selector và điểm shift.
  2. Layout Shift Regions overlay: DevTools → Rendering panel → tick Layout Shift Regions. Vùng vừa shift sẽ nhấp nháy màu xanh tím trong 300ms, thấy ngay phần tử nào là thủ phạm.
  3. web-vitals attribution trong console:
    import { onCLS } from 'web-vitals/attribution';
    onCLS(console.log, { reportAllChanges: true });
    
    Log ra largestShiftTarget (CSS selector), largestShiftSource (DOM node), loadState (đang loading/DOM interactive/complete). loadState = 'loading' thường là ảnh/font; loadState = 'complete' thường là dynamic content hoặc user-triggered animation gọi nhầm.
  4. PageSpeed Insights: nhập URL, xem tab "Origin" cho dữ liệu CrUX 28 ngày, "URL" cho 28 ngày của URL đó. Tab "Diagnostics" liệt kê Avoid large layout shifts với selector cụ thể.
  5. DebugBear hoặc SpeedCurve: nếu team lớn, RUM platform có video replay chính xác moment shift xảy ra. Vô giá khi bug chỉ xuất hiện với 1% người dùng ở một device profile.

Một pitfall cuối: nhiều team chỉ debug trên broadband. Hãy luôn test với CPU throttle 4xSlow 4G trong DevTools; phần lớn CLS chỉ hiện ra khi tài nguyên đến chậm. Kết hợp với chiến lược caching web 2026 để đảm bảo tài nguyên tĩnh tới đủ nhanh và giảm cơ hội trigger shift.

Tham khảo tài liệu gốc: web.dev — Optimize Cumulative Layout Shift có bảng đối chiếu nguyên nhân/giải pháp đầy đủ nhất, và web.dev — CLS spec có công thức chi tiết cùng ví dụ tính toán.

Câu hỏi thường gặp

CLS 0 có phải mục tiêu thực tế không?

Có, nhiều site tĩnh và trang blog đạt CLS = 0 tại P75. Với site có ads, CMP, embed nhiều thì mục tiêu thực tế là CLS ≤ 0.05. Đừng dừng ở 0.09 chỉ vì "đủ pass"; dư địa an toàn giúp bạn không fail khi thêm feature mới.

Vì sao CLS của tôi trong Lighthouse tốt nhưng CrUX xấu?

Lighthouse chỉ đo giai đoạn tải ban đầu (~6s), trong khi CLS trong CrUX tính toàn bộ vòng đời trang gồm scroll, lazy load, dynamic content, và cả iframe (mà JS API không đo được). Luôn tin CrUX/RUM hơn Lighthouse cho CLS.

Có cần preload font khi đã dùng size-adjust không?

Vẫn nên. size-adjust triệt tiêu shift nếu font swap xảy ra, nhưng preload giảm thời gian FOUT (Flash of Unstyled Text), người dùng thấy font đẹp sớm hơn 200–400ms. Hai kỹ thuật bổ trợ nhau, không thay thế nhau.

Layout shift do user click có bị tính CLS không?

Không, nếu shift xảy ra trong 500ms sau tương tác (click, keydown, pointerdown). PerformanceObserver sẽ set hadRecentInput = true và shift bị loại. Nhưng shift muộn hơn 500ms (ví dụ modal mount chậm) vẫn bị tính, đây là bug tôi hay gặp ở SPA.

bfcache có làm CLS tệ hơn không?

Ngược lại. bfcache reset CLS về 0 và người dùng quay lại trang thấy nội dung tức thời, không shift gì. Đảm bảo trang bạn eligible bfcache (không dùng unload listener, không set Cache-Control: no-store) là cách miễn phí cải thiện CLS ở tập user navigation quay lại.

Nadia El-Sayed
Về Tác Giả Nadia El-Sayed

Core Web Vitals specialist focused on real-user monitoring. Believes synthetic-only perf testing is a comforting lie.