Đ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 tốc độ website 2026: Core Web Vitals và Server-Side Rendering

Ếch Trendy
Ếch Trendy

7 tháng trước · 15 phút đọc

Core Web Vitals 2026: Tại sao Google lại quan tâm đến tốc độ của bạn?

Ribbit 🐸 - Mình vừa optimize lại trang blog này, điểm Lighthouse nhảy từ 67 lên 98 chỉ sau một buổi chiều. Ranking Google tăng vọt, tỷ lệ thoát giảm 35%. Không phải phép màu - đây là kết quả của việc hiểu đúng Core Web Vitals.

Google đã update thuật toán Page Experience từ 2021, nhưng 2026 là năm họ siết chặt hơn bao giờ hết. Core Web Vitals không chỉ là "nice to have" - nó quyết định ranking của bạn. Ba chỉ số mấu chốt: LCP (Largest Contentful Paint), FID/INP (First Input Delay/Interaction to Next Paint), và CLS (Cumulative Layout Shift).

LCP đo tốc độ hiển thị nội dung chính, chuẩn Google là dưới 2.5 giây. FID/INP đo độ phản hồi khi người dùng tương tác, cần dưới 200ms. CLS đo độ ổn định layout, dưới 0.1 là tốt. Vượt ngưỡng này, trang web của bạn sẽ bị đánh giá "cần cải thiện" hoặc tệ hơn - "kém".

16175 Ba chỉ số Core Web Vitals quyết định trải nghiệm người dùng và ranking Google

Nhưng đây không chỉ là chuyện SEO. Mình đã test A/B trên một trang landing page: phiên bản tải nhanh hơn 1.2 giây có tỷ lệ chuyển đổi cao hơn 28%. Người dùng không kiên nhẫn - họ rời đi nếu trang của bạn không load trong 3 giây.

Google Search Console giờ đã tích hợp báo cáo Core Web Vitals dựa trên dữ liệu thực tế từ Chrome UX Report. Bạn thấy trang nào "cần cải thiện" là phải vào fix ngay, không để sau.


Lighthouse Audit: Công cụ đo lường không thể thiếu

Lighthouse là công cụ mã nguồn mở của Google, tích hợp sẵn trong Chrome DevTools. Mở DevTools (F12), chọn tab Lighthouse, chạy audit cho cả Desktop và Mobile - bạn sẽ có báo cáo chi tiết về Performance, Accessibility, Best Practices, SEO.

Điểm Performance là tổng hợp từ 6 metrics: FCP (First Contentful Paint), LCP, TBT (Total Blocking Time), CLS, Speed Index, và TTI (Time to Interactive). Mỗi metrics có trọng số khác nhau - LCP chiếm 25%, TBT chiếm 30%. Hiểu trọng số này giúp bạn ưu tiên optimize đúng chỗ.

Mình thường chạy Lighthouse ở chế độ throttling "Slow 4G" để mô phỏng điều kiện mạng thật. Nhiều dev chỉ test trên WiFi nhanh, rồi sốc khi thấy trang web load chậm như rùa trên 4G. PageSpeed Insights (web version của Lighthouse) còn cho bạn thấy dữ liệu thực từ người dùng thật (Field Data) bên cạnh dữ liệu lab (Lab Data).

16176 Lighthouse audit trên Chrome DevTools với các metrics chi tiết và gợi ý tối ưu

Một trick hay: dùng Lighthouse CI để tự động chạy audit mỗi lần push code. Mình setup Lighthouse CI trên GitHub Actions, mỗi pull request đều có báo cáo Performance - không ai merge được code làm chậm trang web.

// lighthouse.config.js - Config Lighthouse CI
module.exports = {
  ci: {
    collect: {
      startServerCommand: 'npm run start',
      url: ['http://localhost:3000', 'http://localhost:3000/blog'],
      numberOfRuns: 3, // Chạy 3 lần lấy trung bình
    },
    assert: {
      assertions: {
        'categories:performance': ['error', { minScore: 0.9 }], // Yêu cầu >= 90 điểm
        'categories:accessibility': ['warn', { minScore: 0.85 }],
      },
    },
  },
};

Lưu ý: điểm Lighthouse chỉ là chỉ số tham khảo, không phải mục tiêu tuyệt đối. Có những trang web 85 điểm nhưng trải nghiệm thực tế vẫn tốt hơn trang 95 điểm. Tập trung vào metrics thực tế từ người dùng thật thông qua Real User Monitoring (RUM).


Server-Side Rendering với Next.js 15: Tăng tốc từ gốc

Xong phần đo lường rồi, giờ sang cách làm trang web nhanh từ kiến trúc. SSR (Server-Side Rendering) render HTML trên server trước khi gửi về browser - khác với CSR (Client-Side Rendering) phải chờ JavaScript load xong mới render.

Next.js 15 mang đến App Router với React Server Components (RSC) - components chạy hoàn toàn trên server, không gửi JS về client. Mình đã migrate một dự án từ Create React App sang Next.js 15, FCP giảm từ 3.2s xuống 0.8s, LCP từ 4.5s xuống 1.6s.

// app/blog/page.js - React Server Component
import { getPosts } from '@/lib/api';

// Component này chạy trên server, fetch data ở server
export default async function BlogPage() {
  const posts = await getPosts(); // Fetch trực tiếp, không cần useEffect
  
  return (
    <div>
      <h1>Blog của mình</h1>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </div>
  );
}

// Metadata cho SEO, cũng generate trên server
export const metadata = {
  title: 'Blog - Tối ưu tốc độ website',
  description: 'Chia sẻ kinh nghiệm tối ưu hiệu năng web',
};

Điểm mạnh của SSR: HTML đầy đủ được gửi ngay từ request đầu tiên, search engine bot thấy nội dung liền. Điểm yếu: server phải xử lý nhiều hơn, tốn tài nguyên. Giải pháp: kết hợp SSG (Static Site Generation) cho nội dung ít thay đổi, SSR cho nội dung động.

16177 So sánh Client-Side Rendering và Server-Side Rendering về timeline tải trang

Next.js 15 có thêm Partial Prerendering (PPR) - tính năng kết hợp static và dynamic content trong cùng một trang. Phần tĩnh (header, footer) được cache, phần động (user data) được render real-time. Kết quả: tốc độ tải nhanh như static site nhưng vẫn cá nhân hóa được.

Bạn cũng nên dùng streaming SSR - gửi HTML về từng phần thay vì đợi toàn bộ trang render xong. React Suspense hỗ trợ điều này cực tốt:

// app/dashboard/page.js
import { Suspense } from 'react';
import UserProfile from '@/components/UserProfile';
import Analytics from '@/components/Analytics';

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      {/* Hiển thị skeleton loading ngay, fetch data sau */}
      <Suspense fallback={<ProfileSkeleton />}>
        <UserProfile />
      </Suspense>
      <Suspense fallback={<AnalyticsSkeleton />}>
        <Analytics />
      </Suspense>
    </div>
  );
}

Cách này giúp FCP cực nhanh vì browser render được layout ngay, dữ liệu stream về dần không block rendering.


Optimize images: Từ 5MB xuống 50KB không mất chất lượng

Hình ảnh thường chiếm 50-70% tổng dung lượng trang web. Optimize ảnh đúng cách có thể giảm thời gian tải xuống một nửa mà người dùng không nhận ra khác biệt.

Next.js 15 có component <Image> tự động optimize: resize theo kích thước hiển thị, convert sang WebP/AVIF, lazy load mặc định, placeholder blur. Bạn chỉ cần dùng thay thẻ <img> thông thường:

import Image from 'next/image';

export default function BlogPost({ post }) {
  return (
    <article>
      <Image
        src={post.coverImage}
        alt={post.title}
        width={1200}
        height={630}
        priority={false} // Lazy load
        placeholder="blur" // Hiển thị blur trước khi load xong
        blurDataURL={post.blurDataURL} // Base64 của ảnh nhỏ
        sizes="(max-width: 768px) 100vw, 50vw" // Responsive sizes
      />
      <h1>{post.title}</h1>
    </article>
  );
}

Các kỹ thuật optimize ảnh mình đang dùng:

Chọn format đúng

  • AVIF: nén tốt nhất, nhưng chưa được hỗ trợ rộng rãi. Dùng cho ảnh có nhiều chi tiết.
  • WebP: nén tốt, hỗ trợ rộng. Ưu tiên dùng cho web.
  • JPEG: fallback cho browser cũ, chất lượng 80-85% là đủ.
  • PNG: chỉ dùng cho ảnh cần nền trong suốt, logo.

16178 So sánh dung lượng file giữa các format ảnh với cùng chất lượng hiển thị

Resize đúng kích thước

Đừng dùng ảnh 3000x2000px rồi CSS scale xuống 300x200px. Tạo nhiều phiên bản ảnh (srcset) cho từng breakpoint:

<img
  srcset="
    /images/hero-400w.webp 400w,
    /images/hero-800w.webp 800w,
    /images/hero-1200w.webp 1200w
  "
  sizes="(max-width: 600px) 400px, (max-width: 1024px) 800px, 1200px"
  src="/images/hero-800w.webp"
  alt="Hero image"
/>

Lazy loading thông minh

Chỉ lazy load ảnh dưới fold (người dùng phải scroll mới thấy). Ảnh above fold cần loading="eager" hoặc priority={true} để tránh làm chậm LCP:

// Ảnh hero - above fold
<Image src="/hero.jpg" alt="Hero" priority />

// Ảnh trong bài - below fold
<Image src="/content.jpg" alt="Content" loading="lazy" />

Mình dùng sharp library để tự động generate nhiều kích thước ảnh khi build:

// scripts/optimize-images.js
const sharp = require('sharp');
const fs = require('fs');

const sizes = [400, 800, 1200, 1600];
const inputDir = './public/images/original';
const outputDir = './public/images/optimized';

fs.readdirSync(inputDir).forEach(filename => {
  sizes.forEach(size => {
    sharp(`${inputDir}/${filename}`)
      .resize(size, null, { withoutEnlargement: true })
      .webp({ quality: 85 })
      .toFile(`${outputDir}/${filename.replace(/\.[^.]+$/, '')}-${size}w.webp`);
  });
});

Kết quả: ảnh hero của mình giảm từ 2.8MB (PNG gốc) xuống 145KB (WebP optimized), LCP từ 5.2s xuống 1.4s.


Lazy loading và code splitting: Chỉ tải những gì cần thiết

Phần này hơi sâu, bám chặt lá sen nhé. Lazy loading không chỉ áp dụng cho ảnh - JavaScript, CSS, thậm chí cả components cũng nên lazy load để giảm bundle size ban đầu.

Code splitting tự động với Next.js

Next.js tự động tách code theo route - mỗi trang là một bundle riêng. Nhưng bạn cũng nên tách thủ công các component nặng:

// app/page.js
import dynamic from 'next/dynamic';

// Component này chỉ load khi cần (khi user scroll đến)
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
  loading: () => <p>Đang tải biểu đồ...</p>,
  ssr: false, // Không render trên server (vì dùng window object)
});

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <HeavyChart data={chartData} />
    </div>
  );
}

Route-based code splitting

Đối với ứng dụng lớn, mỗi route nên có bundle riêng dưới 200KB. Dùng webpack-bundle-analyzer để kiểm tra:

npm install --save-dev @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
  // Config của bạn
});

Chạy ANALYZE=true npm run build để xem treemap các bundle. Mình phát hiện ra lodash chiếm tới 70KB vì import cả library - fix bằng cách chỉ import function cần dùng:

// ❌ SAI: Import toàn bộ lodash (70KB)
import _ from 'lodash';
const result = _.debounce(fn, 300);

// ✅ ĐÚNG: Chỉ import debounce (2KB)
import debounce from 'lodash/debounce';
const result = debounce(fn, 300);

16179 Webpack Bundle Analyzer hiển thị treemap các module chiếm dung lượng

Lazy load third-party scripts

Google Analytics, Facebook Pixel, chat widget... đừng load ngay lúc trang mới mở. Dùng next/script với strategy phù hợp:

import Script from 'next/script';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        {/* Chỉ load sau khi trang idle */}
        <Script
          src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
          strategy="lazyOnload"
        />
        <Script id="google-analytics" strategy="lazyOnload">
          {`
            window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
            gtag('js', new Date());
            gtag('config', 'GA_ID');
          `}
        </Script>
      </body>
    </html>
  );
}

Strategy options:

  • beforeInteractive: Load trước khi trang interactive (cẩn thận với TBT)
  • afterInteractive: Load sau khi trang interactive (mặc định)
  • lazyOnload: Load khi browser idle

Intersection Observer cho lazy load thủ công

Nếu bạn cần lazy load element tùy chỉnh:

import { useEffect, useRef, useState } from 'react';

export default function LazyComponent({ children }) {
  const ref = useRef();
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          observer.disconnect(); // Chỉ load một lần
        }
      },
      { rootMargin: '100px' } // Load trước 100px khi scroll đến
    );

    if (ref.current) observer.observe(ref.current);
    return () => observer.disconnect();
  }, []);

  return (
    <div ref={ref}>
      {isVisible ? children : <div className="skeleton">Đang tải...</div>}
    </div>
  );
}

Kết quả sau khi áp dụng code splitting + lazy load: bundle size giảm từ 450KB xuống 120KB (initial load), TBT từ 890ms xuống 180ms.


Font loading và CSS optimization: Những chi tiết tạo nên sự khác biệt

Font chữ tùy chỉnh làm trang web đẹp hơn nhưng cũng là thủ phạm gây chậm. FOUT (Flash of Unstyled Text) và FOIT (Flash of Invisible Text) làm CLS tăng vọt.

Next.js 15 tích hợp next/font tự động optimize Google Fonts:

// app/layout.js
import { Inter, Playfair_Display } from 'next/font/google';

const inter = Inter({
  subsets: ['latin', 'vietnamese'], // Chỉ load ký tự cần thiết
  display: 'swap', // Hiển thị font hệ thống trước, swap sang font tùy chỉnh sau
  variable: '--font-inter',
});

const playfair = Playfair_Display({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-playfair',
});

export default function RootLayout({ children }) {
  return (
    <html className={`${inter.variable} ${playfair.variable}`}>
      <body>{children}</body>
    </html>
  );
}

Next.js sẽ:

  1. Download font khi build, host trên domain của bạn (tránh CORS, tránh DNS lookup)
  2. Tự động thêm font-display: swap
  3. Preload font chính để giảm FOUT

16180 So sánh font-display strategies: block, swap, fallback, optional

Preload critical resources

Font, CSS, ảnh above-the-fold cần được preload:

// app/layout.js
export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        <link
          rel="preload"
          href="/fonts/custom-font.woff2"
          as="font"
          type="font/woff2"
          crossOrigin="anonymous"
        />
        <link rel="preload" href="/hero-image.webp" as="image" />
      </head>
      <body>{children}</body>
    </html>
  );
}

Lưu ý: chỉ preload tài nguyên thật sự critical (tối đa 2-3 items). Preload quá nhiều phản tác dụng, làm chậm các tài nguyên khác.

CSS optimization

  • Critical CSS: Inline CSS cần thiết cho above-the-fold, defer phần còn lại
  • Remove unused CSS: Dùng PurgeCSS/Tailwind CSS để loại bỏ CSS không dùng
  • Minify: Next.js tự động minify khi build production
// tailwind.config.js
module.exports = {
  content: [
    './app/**/*.{js,ts,jsx,tsx}',
    './components/**/*.{js,ts,jsx,tsx}',
  ],
  // Tailwind tự động loại bỏ CSS không dùng dựa trên content
};

Mình chuyển từ Bootstrap sang Tailwind CSS, CSS bundle giảm từ 180KB xuống 12KB. Tailwind chỉ generate CSS cho class thực sự sử dụng trong code.

Avoid layout shift với aspect-ratio

Đặt kích thước cụ thể cho mọi element để browser tính toán layout ngay từ đầu:

/* Reserve space cho ảnh hero */
.hero-image {
  aspect-ratio: 16 / 9;
  width: 100%;
  height: auto;
}

/* Reserve space cho ad slot */
.ad-slot {
  min-height: 250px;
}

CLS của mình giảm từ 0.18 xuống 0.02 chỉ nhờ thêm aspect-ratio cho ảnh và set min-height cho dynamic content.


Caching strategies và CDN: Tận dụng sức mạnh edge network

Cache đúng cách giúp người dùng quay lại không phải tải lại tài nguyên. Browser cache, CDN cache, server cache - ba lớp cache này kết hợp mang lại hiệu quả tối đa.

Browser cache với Cache-Control headers

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/images/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=31536000, immutable', // Cache 1 năm
          },
        ],
      },
      {
        source: '/:path*.css',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=31536000, immutable',
          },
        ],
      },
      {
        source: '/:path*.js',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=31536000, immutable',
          },
        ],
      },
      {
        source: '/:path*.html',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=0, must-revalidate', // Luôn revalidate HTML
          },
        ],
      },
    ];
  },
};

Quy tắc: static assets (CSS, JS, images) có hash trong tên file -> cache lâu dài. HTML -> cache ngắn hoặc revalidate mỗi lần.

16181 Quy trình cache từ browser đến CDN edge đến origin server

CDN và edge caching

Vercel (nền tảng deploy Next.js) tự động cache static assets trên CDN toàn cầu. Nhưng bạn cũng có thể dùng Cloudflare, AWS CloudFront cho hosting khác.

Mình deploy trang blog lên Vercel, tốc độ truy cập từ Việt Nam:

  • Lần đầu (cache miss): 1.2s
  • Lần sau (cache hit): 0.3s

CDN serve từ edge server gần người dùng nhất, giảm latency đáng kể.

ISR (Incremental Static Regeneration) cho nội dung động

Bạn muốn trang tĩnh (nhanh) nhưng nội dung cập nhật thường xuyên? ISR là giải pháp:

// app/blog/[slug]/page.js
export const revalidate = 3600; // Revalidate sau 1 giờ

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug);
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Trang được generate static khi build, sau đó tự động revalidate theo interval. Người dùng luôn thấy trang nhanh (static), nội dung cập nhật định kỳ.

Service Workers cho offline experience

Trang web vẫn hoạt động khi mất mạng - Progressive Web App (PWA):

// public/sw.js
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('v1').then((cache) => {
      return cache.addAll([
        '/',
        '/styles.css',
        '/script.js',
        '/offline.html',
      ]);
    })
  );
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request);
    })
  );
});

Dùng Workbox (library từ Google) để quản lý Service Worker dễ hơn. Next.js có plugin next-pwa tích hợp sẵn.


Monitoring và continuous optimization: Đo lường để cải thiện

Trang web nhanh hôm nay không đảm bảo nhanh mãi. Bạn cần monitor liên tục để phát hiện regression sớm.

Real User Monitoring (RUM)

Google Analytics 4 + Web Vitals library:

// app/layout.js
import { useEffect } from 'react';
import { onCLS, onFID, onLCP, onINP, onFCP, onTTFB } from 'web-vitals';

function sendToAnalytics({ name, value, id }) {
  // Gửi metrics về Google Analytics
  window.gtag('event', name, {
    event_category: 'Web Vitals',
    event_label: id,
    value: Math.round(name === 'CLS' ? value * 1000 : value),
    non_interaction: true,
  });
}

export default function RootLayout({ children }) {
  useEffect(() => {
    onCLS(sendToAnalytics);
    onFID(sendToAnalytics);
    onLCP(sendToAnalytics);
    onINP(sendToAnalytics);
    onFCP(sendToAnalytics);
    onTTFB(sendToAnalytics);
  }, []);

  return <html><body>{children}</body></html>;
}

Data thật từ người dùng thật quan trọng hơn lab data. Một số người dùng có mạng chậm, device yếu - bạn cần biết trải nghiệm của họ.

16182 Dashboard Google Analytics hiển thị Core Web Vitals từ người dùng thật

Performance budgets

Đặt ngưỡng cho từng metrics, fail build nếu vượt quá:

// lighthouserc.json
{
  "ci": {
    "assert": {
      "assertions": {
        "first-contentful-paint": ["error", { "maxNumericValue": 2000 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "total-blocking-time": ["error", { "maxNumericValue": 300 }]
      }
    }
  }
}

Mỗi lần push code, CI chạy Lighthouse audit, fail nếu không đạt budget. Ngăn chặn performance regression từ đầu.

Synthetic monitoring với Checkly

Checkly chạy Lighthouse audit định kỳ từ nhiều địa điểm, cảnh báo khi có vấn đề:

// checkly.config.js
module.exports = {
  checks: [
    {
      name: 'Homepage Performance',
      frequency: 10, // Chạy mỗi 10 phút
      locations: ['ap-southeast-1', 'us-west-1'], // Singapore + California
      request: {
        url: 'https://example.com',
      },
      assertions: [
        { property: 'performance.lcp', comparison: 'LESS_THAN', target: 2500 },
        { property: 'performance.cls', comparison: 'LESS_THAN', target: 0.1 },
      ],
    },
  ],
};

Nhận email/Slack alert ngay khi metrics xấu đi - fix trước khi người dùng phàn nàn.

A/B testing performance changes

Trước khi deploy optimization lớn, test A/B để chắc chắn không làm hỏng gì:

// Phân chia traffic 50/50
const userBucket = Math.random() < 0.5 ? 'control' : 'experiment';

if (userBucket === 'experiment') {
  // Load optimized version
  import('./optimized-component');
} else {
  // Load original version
  import('./original-component');
}

// Track metrics theo bucket
sendToAnalytics({ bucket: userBucket, metric: 'lcp', value: lcpValue });

So sánh metrics giữa 2 nhóm sau 1 tuần, quyết định có deploy toàn bộ hay không.