← semua materi
15:00 Teknis · 24 Oct 2026 · 15:03

📚 Topik: Core Web Vitals & Bundle Size — Optimasi Frontend yang Terukur

Sesi Performance, level 2. Fokus: metrik apa yang benar-benar diukur browser, cara mengukur di lapangan (bukan cuma Lighthouse), dan kenapa "bundle kecil" bukan tujuan akhir.


🎯 Tujuan Belajar

Setelah sesi ini bisa:

  1. Pasang RUM Core Web Vitals sendiri (tanpa vendor) dengan web-vitals v6 dan atribusi LoAF.
  2. Baca CrUX untuk benchmark realistis, bukan angka Lighthouse lab.
  3. Debug INP dari trace → tahu apakah masalahnya input delay, processing, atau presentation delay.
  4. Set budget bundle size di CI yang gagal build, bukan sekadar dashboard.

🏗️ Arsitektur & Alur Teknis

Tiga metrik, tiga fase berbeda

Metrik Fase Yang diukur Good (p75)
LCP Loading Render elemen konten terbesar ≤ 2.5 s
INP Interactivity Interaksi terlama (≈p98) → paint berikutnya ≤ 200 ms
CLS Visual stability Total skor shift tak terduga ≤ 0.1

Sumber: web.dev/articles/vitals (update terakhir 31 Okt 2024), web.dev/articles/inp (update 2 Sep 2025). DIKETAHUI — ini masih threshold resmi per Oktober 2026.

Anatomi INP (ini yang paling sering disalahpahami)

INP = input delay + processing time + presentation delay
      └─ main thread sibuk     └─ event handler  └─ render + style + layout

INP ≠ waktu semua efek interaksi selesai. INP berhenti di paint berikutnya. Fetch data 800 ms setelah spinner muncul tidak masuk INP. Ini keputusan desain: user butuh feedback visual, bukan hasil akhir.

Konsekuensi praktis: kalau handler async dan await fetch() sebelum update DOM → INP buruk. Kalau update DOM dulu (optimistic UI), lalu fetch → INP bagus meski data sama lambatnya.

Kenapa bundle size masuk hitungan

Bundle JS besar memukul tiga jalur sekaligus: - LCP — parse+compile JS memblokir main thread, menunda render. - INP — lebih banyak kode = lebih besar peluang long task saat interaksi. - TTFB tidak terpengaruh — jangan salah kaitkan.

Tapi korelasi ukuran↔INP tidak linier. 200 KB JS yang dieksekusi saat idle tidak merusak INP. 40 KB JS yang jalan tepat saat user klik bisa. Ukuran adalah proxy, execution timing adalah penyebab. Optimasi yang benar: pindahkan eksekusi keluar dari critical path, bukan sekadar perkecil byte.


💻 Kode Nyata

1. RUM Core Web Vitals + atribusi (web-vitals v6.2.3)

web-vitals v6.0.0 rilis 21 Juli 2026 — breaking change utama: dukungan soft navigation. Versi npm saat ini 6.2.3, 0 dependency, Apache-2.0.

npm i web-vitals@6.2.3
// src/rum.ts — kirim ke endpoint sendiri
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals/attribution';

type Payload = Record<string, unknown>;

function send(metric: any) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,          // 'good' | 'needs-improvement' | 'poor'
    id: metric.id,
    navigationType: metric.navigationType, // 'navigate' | 'reload' | 'back-forward-cache' | 'soft-navigation'
    // atribusi: ini yang bikin bisa di-debug
    target: metric.attribution?.target,
    interactionTarget: metric.attribution?.interactionTarget,
    longestScript: metric.attribution?.longestScript,
    inpBreakdown: metric.attribution
      ? {
          inputDelay: metric.attribution.inputDelay,
          processingDuration: metric.attribution.processingDuration,
          presentationDelay: metric.attribution.presentationDelay,
        }
      : undefined,
    url: location.href,
    ts: Date.now(),
  });
  // sendBeacon supaya tidak hilang saat page unload
  navigator.sendBeacon?.('/api/rum', body) ??
    fetch('/api/rum', { body, method: 'POST', keepalive: true });
}

onLCP(send);
onINP(send);
onCLS(send);
onTTFB(send);

inpBreakdown adalah bagian paling berguna: kalau inputDelay dominan → main thread diblok task lain. Kalau processingDuration dominan → handler-nya sendiri berat. Kalau presentationDelay dominan → layout/style recalc atau render tree terlalu besar.

Penting: build attribution ~1.5 KB lebih besar (brotli) dari build standar. Kalau tidak pakai field atribusi, import dari 'web-vitals' saja.

2. Soft navigation (baru, Chrome 151+)

import { onLCP, onINP, onCLS } from 'web-vitals';

const opts = { reportSoftNavs: true };
onLCP(send, opts);
onINP(send, opts);
onCLS(send, opts);

Dengan flag ini, navigationType jadi 'soft-navigation' dan metrik di-reset per navigasi SPA. Catatan dari dokumentasi resmi: TTFB dilaporkan 0 untuk soft nav; elemen yang tidak repaint tidak dihitung sebagai LCP; CLS dan INP di-lock saat soft nav pertama terjadi — jadi pengukuran initial load ikut berubah.

Hanya berfungsi di Chromium 151+. Browser lain tetap mengukur hard navigation saja, meski flag dipasang. DIKETAHUI dari README npm web-vitals.

3. Yield ke main thread (perbaikan INP paling berdampak)

// scheduler.yield(): Chrome/Edge 129+, Firefox 142+. Safari: belum.
async function yieldToMain(): Promise<void> {
  if ('scheduler' in globalThis && 'yield' in (globalThis as any).scheduler) {
    return (globalThis as any).scheduler.yield();
  }
  return new Promise((r) => setTimeout(r, 0));
}

// Handler berat: render dulu, sisanya dipecah
input.addEventListener('input', (e) => {
  const el = e.target as HTMLInputElement;
  renderText(el.value);            // sinkron, kecil → paint cepat

  void (async () => {
    await yieldToMain();           // beri browser kesempatan paint + handle input lain
    updateWordCount(el.value);
    await yieldToMain();
    checkSpelling(el.value);       // ini yang biasanya 50–200 ms
    saveChanges(el.value);
  })();
});

Trade-off: scheduler.yield() mempertahankan prioritas task (lanjutan task tetap di depan), setTimeout(0) masuk ke antrian belakang → bisa tertunda lebih lama kalau ada task lain. Untuk INP, scheduler.yield() lebih baik. Untuk pekerjaan yang benar-benar tidak urgent, setTimeout justru lebih tepat.

4. Observasi LoAF (Long Animation Frames)

// Chrome 123+. Spec W3C diperbarui 20 Okt 2026.
if (PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')) {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries() as any[]) {
      if (entry.blockingDuration < 100) continue;
      const worst = [...entry.scripts]
        .sort((a, b) => b.duration - a.duration)
        .slice(0, 3)
        .map((s) => ({
          invoker: s.invoker,
          invokerType: s.invokerType,
          duration: Math.round(s.duration),
          sourceURL: s.sourceURL,
          sourceFunctionName: s.sourceFunctionName,
          forcedLayout: Math.round(s.forcedStyleAndLayoutDuration),
        }));
      console.log('LoAF', Math.round(entry.duration), 'ms', worst);
    }
  });
  observer.observe({ type: 'long-animation-frame', buffered: true });
}

forcedStyleAndLayoutDuration adalah goldmine: kalau nilainya besar, ada layout thrashing — baca offsetHeight lalu tulis style berulang dalam loop.

Catatan: buffer getEntriesByType('long-animation-frame') maksimal 200 entri, selebihnya dibuang. Pakai PerformanceObserver, jangan polling.

5. Budget bundle di CI

budget.json untuk Lighthouse CI:

[
  {
    "path": "/*",
    "resourceSizes": [
      { "resourceType": "script", "budget": 170 },
      { "resourceType": "total", "budget": 500 },
      { "resourceType": "image", "budget": 300 }
    ],
    "resourceCounts": [
      { "resourceType": "third-party", "budget": 5 }
    ]
  }
]
npm i -D @lhci/cli@0.15
npx lhci autorun --collect.staticDistDir=dist \
  --assert.budgetsFile=budget.json \
  --assert.assertions.categories:performance=0.9

Lighthouse 13.0.0 (10 Okt 2025) tidak mengubah skor performance — skor dihitung dari metrik, bukan audit. Yang berubah: audit lama dihapus, diganti Performance Insights. Artinya: angka skor 2024 vs 2026 masih sebanding, tapi daftar "opportunities" berubah.

6. Split chunk yang benar (Vite)

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (/react|react-dom|scheduler/.test(id)) return 'react';
            if (/chart|d3|recharts/.test(id)) return 'charts'; // lazy-load, jangan di entry
            return 'vendor';
          }
        },
      },
    },
  },
});

manualChunks untuk library berat hanya berguna kalau chunk-nya tidak di-import di entry. Kalau charts di-import statis di halaman utama, splitting cuma menambah satu round-trip HTTP tanpa mengurangi bytes kritis. Pakai React.lazy() / import() di titik pemakaian.


📊 Angka & Benchmark

Kondisi web nyata (CrUX, publikasi 13 Okt 2026, data September 2026)

Metrik Origins "good" Dengan soft nav
LCP 68.3% 72.1% (+5.6 pp)
CLS 81.7% 85.1% (+4.2 pp)
INP 85.4% 87.4% (+2.3 pp)
Semua CWV lulus 55.9% 59.5% (+6.4 pp)

Total 18.376.044 origins. Sumber: CrUX release notes, halaman diperbarui 13 Okt 2026.

Bacaan: hampir separuh web gagal CWV, dan bottleneck terbesar bukan INP (85% lulus) melainkan LCP (68%). Kalau punya waktu terbatas, kejar LCP dulu — peluang perbaikan lebih besar.

Dampak bisnis (bukan korelasi, A/B test)

Vodafone: perbaikan LCP 31% → sales +8%, lead-to-visit +15%, cart-to-visit +11%. Setup: 50/50 split, ~100K klik/hari per varian, halaman identik secara visual, hanya dioptimasi. Sumber: web.dev/case-studies/vodafone, 17 Mar 2021. DIKETAHUI, tapi data 2021 — jangan klaim sebagai kondisi 2026.

Timeline perubahan resmi

Tanggal Peristiwa
Mar 2024 INP menggantikan FID
9 Sep 2024 FID deprecated, dihapus dari CrUX
12 Des 2025 LCP & INP jadi Baseline Newly available (Safari 26.2)
21 Jul 2026 web-vitals v6.0.0 — soft navigation
13 Okt 2026 Metrik soft nav eksperimental masuk CrUX + BigQuery + API

⚠️ Klaim yang perlu dikoreksi

Beberapa blog SEO 2026 menulis "threshold INP turun dari 200 ms ke 150 ms sejak update Maret 2026". BELUM PASTI — kemungkinan besar salah. Dokumentasi resmi web.dev per update 2 Sep 2025 masih menyebut 200 ms, dan web.dev menyatakan perubahan Core Web Vitals hanya terjadi maksimal sekali setahun dengan notifikasi lebih dulu. Tidak ada pengumuman resmi threshold 150 ms yang bisa saya verifikasi. Jangan pakai angka 150 ms untuk SLA internal tanpa konfirmasi ulang ke changelog resmi.


⚖️ Trade-off & Alternatif

Keputusan Pilih A kalau Pilih B kalau
RUM sendiri vs vendor (SpeedCurve, DebugBear) Butuh kontrol data, volume kecil, tim punya waktu Butuh dashboard + alerting sekarang, tim kecil
web-vitals standard vs attribution Hanya butuh monitoring tren Butuh root-cause; +1.5 KB brotli
Lighthouse CI vs RUM Gate PR, cegah regresi sebelum merge Ukur pengalaman user nyata; Lighthouse ≠ field
scheduler.yield() vs setTimeout(0) Butuh responsivitas cepat (INP) Pekerjaan benar-benar low priority
SSR/streaming vs CSR LCP kritis, konten butuh SEO App behind login, interaktivitas tinggi
Bundle splitting agresif vs monolithic Banyak route, traffic tersebar Satu route utama, cache hit tinggi

Jebakan umum: mengejar skor Lighthouse 100. Skor lab 100 dengan p75 field INP 340 ms itu mungkin — dan field yang menentukan. Lighthouse berguna sebagai regression gate, bukan target.


🏭 Use Case Produksi & Failure Mode

1. Optimasi LCP yang salah sasaran. Tim kompres hero image dari 400 KB → 90 KB, LCP tidak berubah. Penyebab: LCP element bukan gambar itu, tapi <h1> yang menunggu font. Cek element di atribusi LCP dulu — jangan tebak.

2. INP regresi setelah pasang analytics. Third-party tag jalan di main thread saat interaksi. resourceCounts budget untuk third-party biasanya lebih penting dari budget ukuran total. Mitigasi: requestIdleCallback untuk init non-kritis, atau pindah ke Partytown/web worker.

3. hydration mismatch memperlambat TTI tanpa terlihat di LCP. React 18+ hydration besar memblokir main thread setelah paint. LCP bagus, INP hancur pada interaksi pertama. Deteksi: LoAF dengan invokerType: "module-script" dan durasi besar tepat setelah load.

4. CLS dari font swap. font-display: swap bikin teks muncul cepat tapi shift saat font asli load. Pakai size-adjust + ascent-override di @font-face, atau preload font yang benar-benar dipakai above-the-fold.

5. bfcache diblokir unload listener. navigationType tidak pernah back-forward-cache → semua navigasi back diukur sebagai load baru. Cek: tidak ada unload, tidak ada Cache-Control: no-store di HTML.

6. Soft navigation mengubah baseline metrics kamu. Setelah aktifkan reportSoftNavs, angka "initial load" berubah karena CLS/INP di-lock saat soft nav pertama. Bandingkan dengan data sebelum flag, jangan langsung simpulkan regresi.


🛠️ Latihan Praktis (30–45 menit, di laptop)

mkdir cwv-lab && cd cwv-lab
npm init -y
npm i -D @lhci/cli@0.15 serve
npm i web-vitals@6.2.3
  1. Ukur baseline. Ambil satu halaman web publik yang kamu punya/kelola. Jalankan: bash npx lhci collect --url=https://example.com --numberOfRuns=3 npx lhci upload --target=filesystem --outputDir=./lhci-report Bandingkan 3 run. Kalau varians >20% di skor performance, lab-nya tidak stabil — jangan dipakai sebagai gate.

  2. Pasang RUM. Tempel snippet rum.ts (bagian 1) ke halaman, arahkan /api/rum ke https://httpbin.org/post. Buka DevTools → Network, cari request rum. Verifikasi field inpBreakdown terisi.

  3. Paksa INP buruk. Di console: js document.body.addEventListener('click', () => { const t = performance.now(); while (performance.now() - t < 300) {} // block 300ms }); Klik, lalu cek nilai INP di request berikutnya. Lalu ganti ke versi async dengan yieldToMain() dan bandingkan.

  4. Cek LoAF. Dengan observer bagian 4 aktif, klik lagi. Lihat forcedStyleAndLayoutDuration — kalau >0, ada layout sinkron.

  5. Budget gate. Buat budget.json, jalankan npx lhci assert --budgetsFile=budget.json. Lalu naikkan budget script ke 50 KB dan pastikan CI gagal.


✅ Cek Pemahaman

  1. Handler klik memanggil await fetch('/api/cart') lalu update DOM. Total 900 ms. Berapa kontribusi ke INP, dan bagaimana mengubahnya agar INP <200 ms tanpa mempercepat API?
  2. CrUX menunjukkan INP 85.4% good tapi LCP hanya 68.3%. Kalau tim hanya punya kapasitas satu sprint, ke mana dialokasikan dan kenapa?
  3. Kamu menambahkan { reportSoftNavs: true }. Metrik "initial load" berubah dan terlihat lebih buruk. Apakah ini regresi nyata? Jelaskan mekanismenya.

🔑 Poin Kunci


➡️ Eksplorasi Lanjut

Sumber utama: web.dev/articles/{vitals,inp,optimize-inp,optimize-long-tasks}, developer.chrome.com/docs/crux/release-notes (13 Okt 2026), github.com/GoogleChrome/web-vitals CHANGELOG (v6.0.0, 21 Jul 2026), developer.chrome.com/blog/lighthouse-13-0, web.dev/blog/crux-experimental-soft-navs (13 Okt 2026), web.dev/case-studies/vodafone.