Lompat ke konten
·22 menit membaca

Core Web Vitals di 2026: Apa yang Benar-Benar Berdampak

Lupakan teori -- ini yang benar-benar saya lakukan untuk mendapatkan LCP di bawah 2.5 detik, CLS nol, dan INP di bawah 200ms di situs Next.js produksi nyata.

Bagikan:X / TwitterLinkedIn

Saya menghabiskan hampir dua minggu untuk membuat situs ini cepat. Bukan "terlihat cepat di audit Lighthouse di MacBook M3 saya" cepat. Benar-benar cepat. Cepat di HP Android seharga 2 juta di koneksi 4G goyang-goyang di terowongan kereta bawah tanah. Cepat di tempat yang benar-benar penting.

Hasilnya: LCP di bawah 1.8 detik, CLS di 0.00, INP di bawah 120ms. Ketiga metrik hijau di data CrUX, bukan cuma skor lab. Dan saya belajar sesuatu dalam prosesnya — sebagian besar saran performa di internet itu sudah ketinggalan zaman, terlalu umum, atau keduanya.

"Optimalkan gambar Anda" itu bukan saran. "Gunakan lazy loading" tanpa konteks itu berbahaya. "Kurangi JavaScript" itu jelas tapi tidak menjelaskan apa yang harus dipotong.

Ini yang benar-benar saya lakukan, dalam urutan yang paling berdampak.

Mengapa Core Web Vitals Masih Penting di 2026#

Saya akan blak-blakan: Google menggunakan Core Web Vitals sebagai sinyal peringkat. Bukan sinyal satu-satunya, dan bukan yang paling penting juga. Relevansi konten, backlink, dan otoritas domain masih mendominasi. Tapi di margin — di mana dua halaman punya konten dan otoritas yang sebanding — performa jadi penentu. Dan di internet, jutaan halaman hidup di margin itu.

Tapi lupakan SEO sejenak. Alasan sebenarnya untuk peduli soal performa adalah pengguna. Datanya tidak banyak berubah dalam lima tahun terakhir:

  • 53% kunjungan mobile ditinggalkan jika halaman butuh lebih dari 3 detik untuk dimuat (riset Google/SOASTA, masih berlaku)
  • Setiap 100ms latensi kira-kira mengurangi 1% konversi (temuan asli Amazon, sudah divalidasi berulang kali)
  • Pengguna yang mengalami layout shift jauh lebih kecil kemungkinannya untuk menyelesaikan pembelian atau mengisi formulir

Core Web Vitals di 2026 terdiri dari tiga metrik:

MetrikApa yang DiukurBaikPerlu PerbaikanBuruk
LCPPerforma loading≤ 2.5s2.5s – 4.0s> 4.0s
CLSStabilitas visual≤ 0.10.1 – 0.25> 0.25
INPResponsivitas≤ 200ms200ms – 500ms> 500ms

Threshold ini belum berubah sejak INP menggantikan FID pada Maret 2024. Tapi teknik untuk mencapainya sudah berkembang, terutama di ekosistem React/Next.js.

LCP: Yang Paling Berdampak#

Largest Contentful Paint mengukur kapan elemen terlihat terbesar di viewport selesai dirender. Untuk sebagian besar halaman, ini adalah hero image, heading, atau blok teks besar.

Langkah 1: Temukan Elemen LCP Sebenarnya#

Sebelum mengoptimalkan apa pun, kamu harus tahu apa elemen LCP-mu. Orang mengira itu hero image mereka. Kadang itu web font yang merender <h1>. Kadang itu background image dari CSS. Kadang itu poster frame <video>.

Buka Chrome DevTools, buka panel Performance, rekam page load, dan cari marker "LCP". Itu menunjukkan persis elemen mana yang memicu LCP.

Kamu juga bisa menggunakan library web-vitals untuk mencatatnya secara programatik:

tsx
import { onLCP } from "web-vitals";
 
onLCP((metric) => {
  console.log("LCP element:", metric.entries[0]?.element);
  console.log("LCP value:", metric.value, "ms");
});

Di situs ini, elemen LCP ternyata adalah hero image di homepage dan paragraf pertama teks di halaman blog. Dua elemen berbeda, dua strategi optimasi berbeda.

Langkah 2: Preload Resource LCP#

Jika elemen LCP-mu adalah gambar, hal paling berdampak yang bisa kamu lakukan adalah melakukan preload. Secara default, browser menemukan gambar saat mem-parse HTML, yang berarti request gambar tidak dimulai sampai HTML diunduh, di-parse, dan tag <img> tercapai. Preloading memindahkan penemuan itu ke awal sekali.

Di Next.js, kamu bisa menambahkan preload link di layout atau halaman:

tsx
import Head from "next/head";
 
export default function HeroSection() {
  return (
    <>
      <Head>
        <link
          rel="preload"
          as="image"
          href="/images/hero-optimized.webp"
          type="image/webp"
          fetchPriority="high"
        />
      </Head>
      <section className="relative h-[600px]">
        <img
          src="/images/hero-optimized.webp"
          alt="Hero banner"
          width={1200}
          height={600}
          fetchPriority="high"
          decoding="sync"
        />
      </section>
    </>
  );
}

Perhatikan fetchPriority="high". Ini adalah Fetch Priority API yang lebih baru, dan ini game changer. Tanpanya, browser menggunakan heuristiknya sendiri untuk memprioritaskan resource — dan heuristik itu sering salah, terutama kalau ada beberapa gambar di atas fold.

Di situs ini, menambahkan fetchPriority="high" ke gambar LCP menurunkan LCP ~400ms. Itu kemenangan terbesar yang pernah saya dapatkan dari perubahan satu baris.

Langkah 3: Hilangkan Resource yang Memblokir Render#

CSS memblokir rendering. Semuanya. Kalau kamu punya stylesheet 200KB dimuat via <link rel="stylesheet">, browser tidak akan menggambar apa pun sampai semuanya diunduh dan di-parse.

Solusinya ada tiga:

  1. Inline critical CSS — Ekstrak CSS yang dibutuhkan untuk konten above-the-fold dan inline-kan di tag <style> di <head>. Next.js melakukan ini otomatis saat kamu menggunakan CSS Modules atau Tailwind dengan purging yang benar.

  2. Defer CSS non-kritis — Kalau kamu punya stylesheet untuk konten below-the-fold (library animasi footer, komponen chart), muat secara asinkron:

html
<link
  rel="preload"
  href="/styles/charts.css"
  as="style"
  onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
  <link rel="stylesheet" href="/styles/charts.css" />
</noscript>
  1. Hapus CSS yang tidak terpakai — Tailwind CSS v4 melakukan ini otomatis dengan JIT engine-nya. Tapi kalau kamu meng-import library CSS pihak ketiga, audit mereka. Saya menemukan satu component library yang meng-import 180KB CSS untuk satu komponen tooltip. Diganti dengan komponen custom 20 baris dan hemat 170KB.

Langkah 4: Waktu Respons Server (TTFB)#

LCP tidak bisa cepat kalau TTFB lambat. Kalau server-mu butuh 800ms untuk merespons, LCP-mu minimal 800ms + sisanya.

Di situs ini (Node.js + PM2 + Nginx di VPS), saya mengukur TTFB sekitar 180ms pada cold hit. Ini yang saya lakukan untuk menjaganya tetap di situ:

  • ISR (Incremental Static Regeneration) untuk postingan blog — halaman di-pre-render saat build time dan divalidasi ulang secara berkala. Kunjungan pertama menyajikan file statis langsung dari cache reverse proxy Nginx.
  • Edge caching headersCache-Control: public, s-maxage=3600, stale-while-revalidate=86400 pada halaman statis.
  • Kompresi Gzip/Brotli di Nginx — mengurangi ukuran transfer 60-80%.
bash
# nginx.conf snippet
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
gzip_comp_level 6;
 
# Brotli (if ngx_brotli module is installed)
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml;
brotli_comp_level 6;

Sebelum/sesudah LCP saya:

  • Sebelum optimasi: 3.8s (persentil ke-75, CrUX)
  • Setelah preload + fetchPriority + kompresi: 1.8s
  • Total peningkatan: pengurangan 53%

CLS: Kematian dari Seribu Pergeseran#

Cumulative Layout Shift mengukur seberapa banyak konten yang terlihat bergeser selama pemuatan halaman. CLS 0 berarti tidak ada yang bergeser. CLS di atas 0.1 berarti ada sesuatu yang mengganggu pengguna secara visual.

CLS adalah metrik yang paling diremehkan developer. Kamu tidak menyadarinya di mesin development-mu yang cepat dengan semuanya ter-cache. Pengguna menyadarinya di HP mereka, di koneksi lambat, di mana font dimuat terlambat dan gambar muncul satu per satu.

Penyebab Biasa#

1. Gambar tanpa dimensi eksplisit

Ini penyebab CLS paling umum. Saat gambar dimuat, ia mendorong konten di bawahnya ke bawah. Solusinya sangat sederhana: selalu tentukan width dan height pada tag <img>.

tsx
// BURUK — menyebabkan layout shift
<img src="/photo.jpg" alt="Team photo" />
 
// BAIK — browser mereservasi ruang sebelum gambar dimuat
<img src="/photo.jpg" alt="Team photo" width={800} height={450} />

Kalau kamu menggunakan Next.js <Image>, ini ditangani otomatis selama kamu menyediakan dimensi atau menggunakan fill dengan parent container yang sudah diberi ukuran.

Tapi ini jebaknya: kalau kamu menggunakan mode fill, parent container harus punya dimensi eksplisit atau gambar akan menyebabkan CLS:

tsx
// BURUK — parent tidak punya dimensi
<div className="relative">
  <Image src="/photo.jpg" alt="Team" fill />
</div>
 
// BAIK — parent punya aspect ratio eksplisit
<div className="relative aspect-video w-full">
  <Image src="/photo.jpg" alt="Team" fill sizes="100vw" />
</div>

2. Web font menyebabkan FOUT/FOIT

Saat font kustom dimuat, teks yang dirender dengan font fallback di-render ulang dengan font kustom. Kalau kedua font punya metrik berbeda (hampir selalu begitu), semuanya bergeser.

Solusi modern adalah font-display: swap dikombinasikan dengan size-adjusted fallback fonts:

tsx
// Menggunakan next/font — pendekatan terbaik untuk Next.js
import { Inter } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  // next/font otomatis menghasilkan size-adjusted fallback fonts
  // Ini menghilangkan CLS dari font swapping
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

next/font benar-benar salah satu hal terbaik di Next.js. Ia mengunduh font saat build time, melakukan self-host (tidak ada request Google Fonts saat runtime), dan menghasilkan size-adjusted fallback fonts sehingga perpindahan dari fallback ke font kustom tidak menyebabkan layout shift sama sekali. Saya mengukur CLS dari font di 0.00 setelah beralih ke next/font. Sebelumnya, dengan <link> Google Fonts standar, nilainya 0.04-0.08.

3. Injeksi konten dinamis

Iklan, cookie banner, notification bar — apa pun yang diinjeksi ke DOM setelah render awal menyebabkan CLS kalau mendorong konten ke bawah.

Solusinya: reservasi ruang untuk konten dinamis sebelum dimuat.

tsx
// Cookie banner — reservasi ruang di bagian bawah
function CookieBanner() {
  const [accepted, setAccepted] = useState(false);
 
  if (accepted) return null;
 
  return (
    // Positioning fixed tidak menyebabkan CLS karena
    // tidak mempengaruhi document flow
    <div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900 p-4">
      <p>We use cookies. You know the drill.</p>
      <button onClick={() => setAccepted(true)}>Accept</button>
    </div>
  );
}

Menggunakan position: fixed atau position: absolute untuk elemen dinamis adalah pendekatan bebas CLS karena elemen-elemen ini tidak mempengaruhi normal document flow.

4. Trik CSS aspect-ratio

Untuk container responsif di mana kamu tahu aspect ratio tapi tidak tahu dimensi pastinya, gunakan properti CSS aspect-ratio:

tsx
// Embed video tanpa CLS
function VideoEmbed({ src }: { src: string }) {
  return (
    <div className="w-full aspect-video bg-gray-900 rounded-lg overflow-hidden">
      <iframe
        src={src}
        className="w-full h-full"
        title="Embedded video"
        allow="accelerometer; autoplay; clipboard-write; encrypted-media"
        allowFullScreen
      />
    </div>
  );
}

Utility aspect-video (yang merupakan aspect-ratio: 16/9) mereservasi ruang yang tepat. Tidak ada pergeseran saat iframe dimuat.

5. Skeleton screen

Untuk konten yang dimuat secara asinkron (data API, komponen dinamis), tampilkan skeleton yang sesuai dengan dimensi yang diharapkan:

tsx
function PostCardSkeleton() {
  return (
    <div className="animate-pulse rounded-lg border p-4">
      <div className="h-48 w-full rounded bg-gray-200" />
      <div className="mt-4 space-y-2">
        <div className="h-6 w-3/4 rounded bg-gray-200" />
        <div className="h-4 w-full rounded bg-gray-200" />
        <div className="h-4 w-5/6 rounded bg-gray-200" />
      </div>
    </div>
  );
}
 
function PostList() {
  const { data: posts, isLoading } = usePosts();
 
  if (isLoading) {
    return (
      <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
        {Array.from({ length: 6 }).map((_, i) => (
          <PostCardSkeleton key={i} />
        ))}
      </div>
    );
  }
 
  return (
    <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
      {posts?.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

Kuncinya adalah PostCardSkeleton dan PostCard harus punya dimensi yang sama. Kalau skeleton-nya 200px tinggi dan card-nya 280px tinggi, kamu tetap mendapat pergeseran.

Hasil CLS saya:

  • Sebelum: 0.12 (font swap saja 0.06)
  • Sesudah: 0.00 — benar-benar nol, di ribuan page load di data CrUX

INP: Pendatang Baru yang Menggigit#

Interaction to Next Paint menggantikan First Input Delay pada Maret 2024, dan ini metrik yang jauh lebih sulit dioptimasi. FID hanya mengukur delay sebelum interaksi pertama diproses. INP mengukur setiap interaksi sepanjang lifecycle halaman dan melaporkan yang terburuk (di persentil ke-75).

Ini berarti sebuah halaman bisa punya FID bagus tapi INP buruk jika, misalnya, mengklik dropdown menu 30 detik setelah load memicu reflow 500ms.

Apa Penyebab INP Tinggi#

  1. Long task di main thread — Eksekusi JavaScript apa pun yang memakan waktu lebih dari 50ms memblokir main thread. Interaksi pengguna yang terjadi selama long task harus menunggu.
  2. Re-render mahal di React — State update yang menyebabkan 200 komponen di-render ulang butuh waktu. Pengguna mengklik sesuatu, React melakukan reconciliation, dan paint tidak terjadi selama 300ms.
  3. Layout thrashing — Membaca properti layout (seperti offsetHeight) lalu menulisnya (seperti mengubah style.height) dalam loop memaksa browser menghitung ulang layout secara sinkron.
  4. DOM yang besar — Lebih banyak DOM node berarti perhitungan style dan layout lebih lambat. DOM dengan 5.000 node terasa lebih lambat dari yang 500.

Memecah Long Task dengan scheduler.yield()#

Teknik paling berdampak untuk INP adalah memecah long task agar browser bisa memproses interaksi pengguna di antara potongan:

tsx
async function processLargeDataset(items: DataItem[]) {
  const results: ProcessedItem[] = [];
 
  for (let i = 0; i < items.length; i++) {
    results.push(expensiveTransform(items[i]));
 
    // Setiap 10 item, serahkan ke browser
    // Ini memungkinkan interaksi pengguna yang tertunda diproses
    if (i % 10 === 0 && "scheduler" in globalThis) {
      await scheduler.yield();
    }
  }
 
  return results;
}

scheduler.yield() tersedia di Chrome 129+ (September 2024) dan merupakan cara yang direkomendasikan untuk menyerahkan ke main thread. Untuk browser yang tidak mendukungnya, kamu bisa fallback ke wrapper setTimeout(0):

tsx
function yieldToMain(): Promise<void> {
  if ("scheduler" in globalThis && "yield" in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

useTransition untuk Update Non-Urgent#

React 18+ memberi kita useTransition, yang memberi tahu React bahwa state update tertentu tidak urgent dan bisa diinterupsi oleh pekerjaan yang lebih penting (seperti merespons input pengguna):

tsx
import { useState, useTransition } from "react";
 
function SearchableList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState("");
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();
 
  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
 
    // Update ini urgent — input harus mencerminkan keystroke segera
    setQuery(value);
 
    // Update ini TIDAK urgent — memfilter 10.000 item bisa menunggu
    startTransition(() => {
      const filtered = items.filter((item) =>
        item.name.toLowerCase().includes(value.toLowerCase())
      );
      setFilteredItems(filtered);
    });
  }
 
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={handleSearch}
        placeholder="Search..."
        className="w-full rounded border px-4 py-2"
      />
      {isPending && (
        <p className="mt-2 text-sm text-gray-500">Filtering...</p>
      )}
      <ul className="mt-4 space-y-2">
        {filteredItems.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

Tanpa startTransition, mengetik di search input akan terasa lambat karena React akan mencoba memfilter 10.000 item secara sinkron sebelum mengupdate DOM. Dengan startTransition, input diupdate segera, dan filtering terjadi di background.

Saya mengukur INP di halaman tool yang punya input handler kompleks. Sebelum useTransition: INP 380ms. Sesudah: INP 90ms. Itu peningkatan 76% dari perubahan API React.

Debouncing Input Handler#

Untuk handler yang memicu operasi mahal (API call, komputasi berat), debounce mereka:

tsx
import { useCallback, useRef } from "react";
 
function useDebounce<T extends (...args: unknown[]) => void>(
  fn: T,
  delay: number
): T {
  const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
 
  return useCallback(
    ((...args: unknown[]) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
      timeoutRef.current = setTimeout(() => fn(...args), delay);
    }) as T,
    [fn, delay]
  );
}
 
// Penggunaan
function LiveSearch() {
  const [results, setResults] = useState<SearchResult[]>([]);
 
  const search = useDebounce(async (query: string) => {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    const data = await response.json();
    setResults(data.results);
  }, 300);
 
  return (
    <input
      type="text"
      onChange={(e) => search(e.target.value)}
      placeholder="Search..."
    />
  );
}

300ms adalah nilai debounce andalan saya. Cukup singkat sehingga pengguna tidak merasakan delay-nya, cukup lama untuk mencegah firing di setiap keystroke.

Web Worker untuk Komputasi Berat#

Kalau kamu punya komputasi yang benar-benar berat (parsing JSON besar, manipulasi gambar, kalkulasi kompleks), pindahkan sepenuhnya dari main thread:

tsx
// worker.ts
self.addEventListener("message", (event) => {
  const { data, operation } = event.data;
 
  switch (operation) {
    case "sort": {
      // Ini bisa memakan 500ms untuk dataset besar
      const sorted = data.sort((a: number, b: number) => a - b);
      self.postMessage({ result: sorted });
      break;
    }
    case "filter": {
      const filtered = data.filter((item: DataItem) =>
        complexFilterLogic(item)
      );
      self.postMessage({ result: filtered });
      break;
    }
  }
});
tsx
// useWorker.ts
import { useEffect, useRef, useCallback } from "react";
 
function useWorker() {
  const workerRef = useRef<Worker>();
 
  useEffect(() => {
    workerRef.current = new Worker(
      new URL("../workers/worker.ts", import.meta.url)
    );
    return () => workerRef.current?.terminate();
  }, []);
 
  const process = useCallback(
    (operation: string, data: unknown): Promise<unknown> => {
      return new Promise((resolve) => {
        if (!workerRef.current) return;
 
        workerRef.current.onmessage = (event) => {
          resolve(event.data.result);
        };
 
        workerRef.current.postMessage({ operation, data });
      });
    },
    []
  );
 
  return { process };
}

Web Worker beroperasi di thread terpisah, jadi komputasi 2 detik pun sama sekali tidak mempengaruhi INP. Main thread tetap bebas untuk menangani interaksi pengguna.

Hasil INP saya:

  • Sebelum: 340ms (interaksi terburuk adalah tool regex tester dengan penanganan input kompleks)
  • Setelah useTransition + debouncing: 110ms
  • Peningkatan: pengurangan 68%

Kemenangan Spesifik Next.js#

Kalau kamu menggunakan Next.js (13+ dengan App Router), kamu punya akses ke beberapa primitif performa yang kebanyakan developer tidak sepenuhnya manfaatkan.

next/image — Tapi Dikonfigurasi dengan Benar#

next/image bagus, tapi konfigurasi default-nya menyisakan performa di meja:

tsx
// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  images: {
    formats: ["image/avif", "image/webp"],
    deviceSizes: [640, 750, 828, 1080, 1200],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],
    minimumCacheTTL: 60 * 60 * 24 * 365, // 1 tahun
  },
};
 
export default nextConfig;

Pengaturan kunci:

  • formats: ["image/avif", "image/webp"] — AVIF 20-50% lebih kecil dari WebP. Urutannya penting: Next.js mencoba AVIF dulu, fallback ke WebP, lalu ke format asli.
  • minimumCacheTTL — Default 60 detik. Untuk blog, gambar tidak berubah. Cache selama setahun.
  • deviceSizes dan imageSizes — Default menyertakan 3840px. Kecuali kamu menyajikan gambar 4K, potong daftar ini. Setiap ukuran menghasilkan gambar cache terpisah, dan ukuran yang tidak terpakai membuang ruang disk dan waktu build.

Dan selalu gunakan prop sizes untuk memberi tahu browser berapa ukuran gambar akan dirender:

tsx
// Hero image full-width
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  sizes="100vw"
  priority // Gambar LCP — jangan lazy load!
/>
 
// Gambar card di grid responsif
<Image
  src="/card.jpg"
  alt="Card"
  width={400}
  height={300}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

Tanpa sizes, browser mungkin mengunduh gambar 1200px untuk slot 300px. Itu byte terbuang dan waktu terbuang.

Prop priority di gambar LCP sangat penting. Ia menonaktifkan lazy loading dan menambahkan fetchPriority="high" secara otomatis. Kalau elemen LCP-mu adalah next/image, cukup tambahkan priority dan kamu sudah hampir sampai.

next/font — Font Tanpa Layout Shift#

Saya sudah membahas ini di bagian CLS, tapi layak ditekankan. next/font adalah satu-satunya solusi loading font yang saya lihat secara konsisten mencapai nol CLS:

tsx
import { Inter, JetBrains_Mono } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
});
 
const jetbrainsMono = JetBrains_Mono({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-mono",
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html
      lang="en"
      className={`${inter.variable} ${jetbrainsMono.variable}`}
    >
      <body className="font-sans">{children}</body>
    </html>
  );
}

Dua font, nol CLS, nol request eksternal saat runtime. Font-nya diunduh saat build time dan disajikan dari domain-mu sendiri.

Streaming dengan Suspense#

Di sinilah Next.js jadi benar-benar menarik untuk performa. Dengan App Router, kamu bisa melakukan streaming bagian halaman ke browser saat siap:

tsx
import { Suspense } from "react";
import { PostList } from "@/components/blog/PostList";
import { Sidebar } from "@/components/blog/Sidebar";
import { PostListSkeleton } from "@/components/blog/PostListSkeleton";
import { SidebarSkeleton } from "@/components/blog/SidebarSkeleton";
 
export default function BlogPage() {
  return (
    <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
      <div className="lg:col-span-2">
        {/* Ini dimuat cepat — stream segera */}
        <h1 className="text-4xl font-bold">Blog</h1>
 
        {/* Ini butuh query database — stream saat siap */}
        <Suspense fallback={<PostListSkeleton />}>
          <PostList />
        </Suspense>
      </div>
 
      <aside>
        {/* Sidebar bisa dimuat secara independen */}
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar />
        </Suspense>
      </aside>
    </div>
  );
}

Browser menerima shell (heading, navigasi, layout) segera. Daftar postingan dan sidebar di-stream saat datanya tersedia. Pengguna melihat initial load yang cepat, dan konten terisi secara progresif.

Ini sangat powerful untuk LCP. Kalau elemen LCP-mu adalah heading (bukan daftar postingan), ia dirender segera terlepas dari berapa lama query database memakan waktu.

Route Segment Configuration#

Next.js memungkinkan kamu mengkonfigurasi caching dan revalidasi di level route segment:

tsx
// app/blog/page.tsx
// Revalidasi halaman ini setiap jam
export const revalidate = 3600;
 
// app/tools/[slug]/page.tsx
// Halaman tool ini sepenuhnya statis — generate saat build time
export const dynamic = "force-static";
 
// app/api/search/route.ts
// API route — jangan pernah cache
export const dynamic = "force-dynamic";

Di situs ini, postingan blog menggunakan revalidate = 3600 (1 jam). Halaman tool menggunakan force-static karena kontennya tidak pernah berubah antar deployment. Search API menggunakan force-dynamic karena setiap request unik.

Hasilnya: sebagian besar halaman disajikan dari cache statis, TTFB di bawah 50ms untuk halaman ter-cache, dan server nyaris tidak berkeringat.

Alat Pengukuran: Percaya Data, Bukan Matamu#

Persepsimu tentang performa tidak bisa diandalkan. Mesin development-mu punya RAM 32GB, SSD NVMe, dan koneksi gigabit. Pengguna-mu tidak.

Stack Pengukuran yang Saya Gunakan#

1. Chrome DevTools Performance Panel

Alat paling detail yang tersedia. Rekam page load, lihat flamechart, identifikasi long task, temukan resource yang memblokir render. Di sini saya menghabiskan sebagian besar waktu debugging.

Hal kunci yang dicari:

  • Sudut merah di task = long task (>50ms)
  • Event Layout/Paint yang dipicu oleh JavaScript
  • Blok "Evaluate Script" besar (terlalu banyak JavaScript)
  • Network waterfall yang menunjukkan resource ditemukan terlambat

2. Lighthouse

Bagus untuk pengecekan cepat, tapi jangan mengoptimasi untuk skor Lighthouse. Lighthouse berjalan di lingkungan throttled simulasi yang tidak sepenuhnya mencerminkan kondisi dunia nyata. Saya pernah melihat halaman skor 98 di Lighthouse dan punya LCP 4 detik di lapangan.

Gunakan Lighthouse untuk panduan arah, bukan sebagai papan skor.

3. PageSpeed Insights

Alat paling penting untuk situs produksi karena menampilkan data CrUX nyata — pengukuran aktual dari pengguna Chrome nyata selama 28 hari terakhir. Data lab memberi tahu apa yang bisa terjadi. Data CrUX memberi tahu apa yang memang terjadi.

4. Library web-vitals

Tambahkan ini ke situs produksimu untuk mengumpulkan metrik pengguna nyata:

tsx
// components/analytics/WebVitals.tsx
"use client";
 
import { useEffect } from "react";
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
import type { Metric } from "web-vitals";
 
function sendToAnalytics(metric: Metric) {
  // Kirim ke endpoint analytics
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating, // "good" | "needs-improvement" | "poor"
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
  });
 
  // Gunakan sendBeacon agar tidak memblokir page unload
  if (navigator.sendBeacon) {
    navigator.sendBeacon("/api/vitals", body);
  } else {
    fetch("/api/vitals", {
      body,
      method: "POST",
      keepalive: true,
    });
  }
}
 
export function WebVitals() {
  useEffect(() => {
    onCLS(sendToAnalytics);
    onINP(sendToAnalytics);
    onLCP(sendToAnalytics);
    onFCP(sendToAnalytics);
    onTTFB(sendToAnalytics);
  }, []);
 
  return null;
}

Ini memberimu data seperti CrUX milikmu sendiri, tapi dengan lebih banyak detail. Kamu bisa melakukan segmentasi berdasarkan halaman, tipe perangkat, kecepatan koneksi, wilayah geografis — apa pun yang kamu butuhkan.

5. Chrome User Experience Report (CrUX)

Dataset BigQuery CrUX gratis dan berisi data rolling 28 hari untuk jutaan origin. Kalau situsmu mendapat trafik cukup, kamu bisa mengquery datamu sendiri:

sql
SELECT
  origin,
  p75_lcp,
  p75_cls,
  p75_inp,
  form_factor
FROM
  `chrome-ux-report.materialized.metrics_summary`
WHERE
  origin = 'https://yoursite.com'
  AND yyyymm = 202603

Daftar Pembunuh Waterfall#

Script pihak ketiga adalah pembunuh performa nomor satu di kebanyakan website. Ini yang saya temukan dan apa yang saya lakukan.

Google Tag Manager (GTM)#

GTM sendiri ~80KB. Tapi GTM memuat script lain — analytics, marketing pixel, tool A/B testing. Saya pernah melihat konfigurasi GTM yang memuat 15 script tambahan totalnya 2MB.

Pendekatan saya: Jangan gunakan GTM di produksi. Muat script analytics langsung, defer semuanya, dan gunakan loading="lazy" untuk script yang bisa menunggu:

tsx
// Alih-alih GTM memuat semuanya
// Muat hanya yang kamu butuhkan, saat kamu butuhkan
 
export function AnalyticsScript() {
  return (
    <script
      defer
      src="https://analytics.example.com/script.js"
      data-website-id="your-id"
    />
  );
}

Kalau kamu benar-benar harus menggunakan GTM, muat setelah halaman interaktif:

tsx
"use client";
 
import { useEffect } from "react";
 
export function DeferredGTM({ containerId }: { containerId: string }) {
  useEffect(() => {
    // Tunggu setelah page load untuk menginjeksi GTM
    const timer = setTimeout(() => {
      const script = document.createElement("script");
      script.src = `https://www.googletagmanager.com/gtm.js?id=${containerId}`;
      script.async = true;
      document.head.appendChild(script);
    }, 3000); // delay 3 detik
 
    return () => clearTimeout(timer);
  }, [containerId]);
 
  return null;
}

Ya, kamu akan kehilangan data dari pengguna yang bounce dalam 3 detik pertama. Dari pengalaman saya, itu trade-off yang layak. Pengguna-pengguna itu tidak akan konversi juga.

Chat Widget#

Chat widget (Intercom, Drift, Crisp) adalah beberapa pelanggar terburuk. Intercom saja memuat 400KB+ JavaScript. Di halaman di mana 2% pengguna benar-benar mengklik tombol chat, itu 400KB JavaScript untuk 98% pengguna.

Solusi saya: Muat widget saat interaksi.

tsx
"use client";
 
import { useState } from "react";
 
export function ChatButton() {
  const [loaded, setLoaded] = useState(false);
 
  function loadChat() {
    if (loaded) return;
 
    // Muat script chat widget hanya saat pengguna mengklik
    const script = document.createElement("script");
    script.src = "https://chat-widget.example.com/widget.js";
    script.onload = () => {
      // Inisialisasi widget setelah script dimuat
      window.ChatWidget?.open();
    };
    document.head.appendChild(script);
    setLoaded(true);
  }
 
  return (
    <button
      onClick={loadChat}
      className="fixed bottom-4 right-4 rounded-full bg-blue-600 p-4 text-white shadow-lg"
      aria-label="Open chat"
    >
      {loaded ? "Loading..." : "Chat with us"}
    </button>
  );
}

JavaScript yang Tidak Terpakai#

Jalankan Coverage di Chrome DevTools (Ctrl+Shift+P > "Show Coverage"). Ini menunjukkan persis berapa banyak dari setiap script yang benar-benar digunakan di halaman saat ini.

Di situs Next.js tipikal, biasanya saya menemukan:

  • Component library dimuat seluruhnya — Kamu meng-import Button dari UI library, tapi seluruh library masuk bundle. Solusi: gunakan library tree-shakeable atau import dari subpath (import Button from "lib/Button" alih-alih import { Button } from "lib").
  • Polyfill untuk browser modern — Cek apakah kamu mengirim polyfill untuk Promise, fetch, atau Array.prototype.includes. Di 2026, kamu tidak membutuhkannya.
  • Dead feature flag — Code path di balik feature flag yang sudah "on" selama enam bulan. Hapus flag-nya dan branch yang mati.

Saya menggunakan Next.js bundle analyzer untuk menemukan chunk yang terlalu besar:

tsx
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
 
const nextConfig = {
  // config kamu
};
 
export default process.env.ANALYZE === "true"
  ? withBundleAnalyzer({ enabled: true })(nextConfig)
  : nextConfig;
bash
ANALYZE=true npm run build

Ini membuka treemap visual dari bundle-mu. Saya menemukan library formatting tanggal 120KB yang saya ganti dengan Intl.DateTimeFormat bawaan. Saya menemukan markdown parser 90KB yang di-import di halaman yang tidak menggunakan markdown. Kemenangan kecil yang menumpuk.

CSS yang Memblokir Render#

Saya sudah menyebutkan ini di bagian LCP, tapi layak diulang karena sangat umum. Setiap <link rel="stylesheet"> di <head> memblokir rendering. Kalau kamu punya lima stylesheet, browser menunggu kelima-limanya sebelum menggambar apa pun.

Next.js dengan Tailwind menangani ini dengan baik — CSS di-inline dan minimal. Tapi kalau kamu meng-import CSS pihak ketiga, audit:

tsx
// BURUK — memuat seluruh CSS library di setiap halaman
import "some-library/dist/styles.css";
 
// LEBIH BAIK — dynamic import agar hanya dimuat di halaman yang membutuhkan
const SomeComponent = dynamic(
  () => import("some-library").then((mod) => {
    // CSS di-import di dalam komponen dinamis
    import("some-library/dist/styles.css");
    return mod.SomeComponent;
  }),
  { ssr: false }
);

Kisah Optimasi Nyata#

Saya akan memandu langkah-langkah optimasi aktual halaman tools di situs ini. Halaman dengan 15+ tool interaktif, masing-masing dengan komponen sendiri, dan beberapa di antaranya (seperti regex tester dan JSON formatter) sangat berat JavaScript.

Titik Awal#

Pengukuran awal (data CrUX, mobile, persentil ke-75):

  • LCP: 3.8s — Buruk
  • CLS: 0.12 — Perlu Perbaikan
  • INP: 340ms — Buruk

Skor Lighthouse: 62.

Investigasi#

Analisis LCP: Elemen LCP adalah heading halaman (<h1>), yang seharusnya dirender instan. Tapi tertunda oleh:

  1. File CSS 200KB dari component library (memblokir render)
  2. Font kustom dimuat via CDN Google Fonts (FOIT 800ms di koneksi lambat)
  3. TTFB 420ms karena halaman di-render server di setiap request tanpa caching

Analisis CLS: Tiga sumber:

  1. Font swap dari fallback Google Fonts ke font kustom: 0.06
  2. Tool card dimuat tanpa reservasi tinggi: 0.04
  3. Cookie banner diinjeksi di atas halaman, mendorong semuanya ke bawah: 0.02

Analisis INP: Tool regex tester adalah pelanggar terburuk. Setiap keystroke di input regex memicu:

  1. Full re-render seluruh komponen tool
  2. Evaluasi regex terhadap test string
  3. Syntax highlighting dari pola regex

Total waktu per keystroke: 280-400ms.

Perbaikan#

Minggu 1: LCP dan CLS

  1. Mengganti CDN Google Fonts dengan next/font. Font sekarang di-self-host, dimuat saat build time, dengan fallback size-adjusted. CLS dari font: 0.06 → 0.00

  2. Menghapus CSS component library. Menulis ulang 3 komponen yang saya gunakan darinya dengan Tailwind. Total CSS dihapus: 180KB. CSS yang memblokir render: dihilangkan

  3. Menambahkan revalidate = 3600 ke halaman tools dan halaman detail tool. Hit pertama di-render server, hit berikutnya disajikan dari cache. TTFB: 420ms → 45ms (ter-cache)

  4. Menambahkan dimensi eksplisit ke semua komponen tool card dan menggunakan aspect-ratio untuk layout responsif. CLS dari card: 0.04 → 0.00

  5. Memindahkan cookie banner ke position: fixed di bagian bawah layar. CLS dari banner: 0.02 → 0.00

Minggu 2: INP

  1. Membungkus komputasi hasil regex tester dalam startTransition:
tsx
function RegexTester() {
  const [pattern, setPattern] = useState("");
  const [testString, setTestString] = useState("");
  const [results, setResults] = useState<RegexResult[]>([]);
  const [isPending, startTransition] = useTransition();
 
  function handlePatternChange(value: string) {
    setPattern(value); // Urgent: update input
 
    startTransition(() => {
      // Non-urgent: hitung match
      try {
        const regex = new RegExp(value, "g");
        const matches = [...testString.matchAll(regex)];
        setResults(
          matches.map((m) => ({
            match: m[0],
            index: m.index ?? 0,
            groups: m.groups,
          }))
        );
      } catch {
        setResults([]);
      }
    });
  }
 
  return (
    <div>
      <input
        value={pattern}
        onChange={(e) => handlePatternChange(e.target.value)}
        className={isPending ? "opacity-70" : ""}
      />
      {/* rendering hasil */}
    </div>
  );
}

INP di regex tester: 380ms → 85ms

  1. Menambahkan debouncing ke input handler JSON formatter (delay 300ms). INP di JSON formatter: 260ms → 60ms

  2. Memindahkan komputasi hash generator ke Web Worker. Hashing SHA-256 input besar sekarang terjadi sepenuhnya di luar main thread. INP di hash generator: 200ms → 40ms

Hasilnya#

Setelah dua minggu optimasi (data CrUX, mobile, persentil ke-75):

  • LCP: 3.8s → 1.8s (peningkatan 53%)
  • CLS: 0.12 → 0.00 (peningkatan 100%)
  • INP: 340ms → 110ms (peningkatan 68%)

Skor Lighthouse: 62 → 97.

Ketiga metrik solid di rentang "Baik". Halaman terasa instan di mobile. Dan trafik pencarian organik meningkat 12% di bulan setelah perbaikan (meskipun saya tidak bisa membuktikan kausalitas — faktor lain juga berperan).

Checklist#

Kalau kamu tidak mengambil apa pun lagi dari postingan ini, ini checklist yang saya jalankan di setiap proyek:

LCP#

  • Identifikasi elemen LCP dengan DevTools
  • Tambahkan priority (atau fetchPriority="high") ke gambar LCP
  • Preload resource LCP di <head>
  • Hilangkan CSS yang memblokir render
  • Self-host font dengan next/font
  • Aktifkan kompresi Brotli/Gzip
  • Gunakan static generation atau ISR jika memungkinkan
  • Set cache header agresif untuk aset statis

CLS#

  • Semua gambar punya width dan height eksplisit
  • Menggunakan next/font dengan fallback size-adjusted
  • Konten dinamis menggunakan position: fixed/absolute atau ruang yang direservasi
  • Skeleton screen sesuai dimensi komponen sebenarnya
  • Tidak ada injeksi konten di atas halaman setelah load

INP#

  • Tidak ada long task (>50ms) selama interaction handler
  • State update non-urgent dibungkus dalam startTransition
  • Input handler di-debounce (300ms)
  • Komputasi berat dipindahkan ke Web Worker
  • Ukuran DOM di bawah 1.500 node jika memungkinkan

Umum#

  • Script pihak ketiga dimuat setelah halaman interaktif
  • Ukuran bundle dianalisis dan di-tree-shake
  • CSS yang tidak terpakai dihapus
  • Gambar disajikan dalam format AVIF/WebP
  • Real user monitoring di produksi (library web-vitals)

Penutup#

Optimasi performa bukan tugas sekali jadi. Ini adalah disiplin. Setiap fitur baru, setiap dependency baru, setiap script pihak ketiga baru adalah potensi regresi. Situs yang tetap cepat adalah yang ada seseorang mengawasi metriknya terus-menerus, bukan yang seseorang melakukan sprint optimasi sekali saja.

Pasang real user monitoring. Pasang alert saat metrik regresi. Jadikan performa bagian dari proses code review. Saat seseorang menambahkan library 200KB, tanya apakah ada alternatif 5KB. Saat seseorang menambahkan komputasi sinkron di event handler, tanya apakah bisa di-defer atau dipindahkan ke worker.

Teknik-teknik di postingan ini bukan teoretis. Ini yang benar-benar saya lakukan, di situs ini, dengan angka nyata untuk mendukungnya. Pengalamanmu mungkin berbeda — setiap situs berbeda, setiap audiens berbeda, setiap infrastruktur berbeda. Tapi prinsipnya universal: muat lebih sedikit, muat lebih cerdas, jangan blokir main thread.

Pengguna-mu tidak akan mengirim ucapan terima kasih untuk situs yang cepat. Tapi mereka akan bertahan. Mereka akan kembali. Dan Google akan menyadarinya.

Artikel Terkait