Optimasi Core Web Vitals di Aplikasi React
Apa itu Core Web Vitals?
Core Web Vitals adalah metrik resmi Google untuk mengukur user experience halaman web:
- LCP (Largest Contentful Paint): waktu render elemen terbesar — target < 2.5s
- INP (Interaction to Next Paint): responsivitas interaksi — target < 200ms
- CLS (Cumulative Layout Shift): stabilitas visual — target < 0.1
Skor ini memengaruhi SEO ranking. Dalam project e-commerce klien saya, setelah optimasi 2 minggu, organic traffic naik 35%.
1. Menganalisis Bundle Size
# Install analyzer webpack
npm install --save-dev webpack-bundle-analyzer
# Atau gunakan vite-bundle-visualizer untuk Vite
npm install --save-dev rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
visualizer({ open: true, filename: 'bundle-stats.html' }),
],
});
Setelah build, buka bundle-stats.html. Identifikasi chunks besar yang bisa di-split.
2. Code Splitting dengan React.lazy
Jangan import semua komponen sekaligus. Lazy load yang berat.
// 🚫 Semua dimuat di awal
import HeavyChart from './charts/HeavyChart';
import HeavyModal from './modals/HeavyModal';
import PdfViewer from './viewers/PdfViewer';
// ✅ Lazy load saat dibutuhkan
import { lazy, Suspense } from 'react';
const HeavyChart = lazy(() => import('./charts/HeavyChart'));
const HeavyModal = lazy(() => import('./modals/HeavyModal'));
const PdfViewer = lazy(() => import('./viewers/PdfViewer'));
function App() {
return (
<Suspense fallback={<Skeleton />}>
<HeavyChart />
</Suspense>
);
}
Bundle chunks terpisah hanya diunduh saat komponen pertama dipakai.
3. Tree Shaking yang Proper
// 🚫 Import seluruh library lodash
import _ from 'lodash';
_.debounce(fn, 300);
// ✅ Import hanya method yang dibutuhkan
import debounce from 'lodash/debounce';
debounce(fn, 300);
// Atau gunakan lodash-es yang tree-shakeable
import { debounce } from 'lodash-es';
Cek dengan ESLint rule no-restricted-imports.
// .eslintrc
{
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [{
"name": "lodash",
"message": "Import dari lodash spesifik method: import debounce from 'lodash/debounce'"
}]
}
]
}
}
4. Optimasi Image yang Krusial untuk LCP
Gambar hero sering jadi elemen LCP. Optimasi langsung memengaruhi LCP.
// next/image atau komponen custom
import { useEffect, useState } from 'react';
function OptimizedImage({ src, alt, priority }: { src: string; alt: string; priority?: boolean }) {
return (
<img
src={src}
alt={alt}
loading={priority ? 'eager' : 'lazy'}
decoding="async"
sizes="(max-width: 768px) 100vw, 50vw"
style={{ width: '100%', height: 'auto' }}
/>
);
}
// Hero image (LCP)
<OptimizedImage src="/hero.jpg" priority alt="Hero" />
// Below-fold
<OptimizedImage src="/feature.jpg" alt="Feature" />
Tips format gambar:
- Gunakan AVIF atau WebP (90% browsers support)
- Hapus EXIF metadata:
imagemagick/sharp - Sediakan thumbnail LQIP (Low Quality Image Placeholder) untuk blur-up effect
5. Mengurangi CLS (Layout Shift)
Penyebab CLS tinggi:
- Gambar tanpa dimensi → browser tidak tahu ukuran sebelum load
- Font web yang menggantikan font fallback
- Banner/konten yang inject setelah load
// ✅ Selalu set aspect ratio untuk gambar
<div className="aspect-video">
<img src="/video-thumbnail.jpg" className="w-full h-full object-cover" />
</div>
// ✅ Cadangkan ruang font
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
fallback: ['system-ui', 'sans-serif'],
});
6. Memoization untuk Mengurangi Re-render
import { memo, useMemo, useCallback } from 'react';
// Pure component tidak perlu re-render kalau props sama
const UserCard = memo(function UserCard({ user, onClick }: Props) {
return (
<div onClick={onClick}>
<h3>{user.name}</h3>
</div>
);
});
// Stable callbacks
function UserList({ users }: Props) {
const [filter, setFilter] = useState('');
const handleClick = useCallback((id: string) => {
// Logic stable
}, []);
// Filter hanya recompute kalau users atau filter berubah
const filtered = useMemo(() =>
users.filter(u => u.name.includes(filter)),
[users, filter]
);
return filtered.map(u => <UserCard key={u.id} user={u} onClick={handleClick} />);
}
7. Virtualisasi List Panjang
// react-window untuk list ribuan item
import { FixedSizeList } from 'react-window';
function BigList({ items }: { items: Item[] }) {
return (
<FixedSizeList
height={600}
width="100%"
itemCount={items.length}
itemSize={80}
>
{({ index, style }) => (
<div style={style}>
<ItemCard item={items[index]} />
</div>
)}
</FixedSizeList>
);
}
Hanya baris yang terlihat yang dirender. Memory footprint constant.
8. Service Worker untuk Caching
// next-pwa atau workbox custom
import withPWAInit from 'next-pwa';
const withPWA = withPWAInit({
dest: 'public',
cacheOnFrontEndNav: true,
aggressiveFrontEndNavCaching: true,
runtimeCaching: [
{
urlPattern: /^https?.*\.(?:png|jpg|jpeg|webp|avif)$/,
handler: 'CacheFirst',
options: { cacheName: 'images', expiration: { maxEntries: 100 } },
},
],
});
9. Monitoring Real User Metrics (RUM)
// web-vitals library dari Google
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(metric => sendToAnalytics('lcp', metric.value));
onINP(metric => sendToAnalytics('inp', metric.value));
onCLS(metric => sendToAnalytics('cls', metric.value));
function sendToAnalytics(metric: string, value: number) {
navigator.sendBeacon('/api/metrics', JSON.stringify({ metric, value, page: location.pathname }));
}
Kirim ke analytics untuk memantau score real user, bukan hanya Lighthouse local.
Kesimpulan
Optimasi Core Web Vitals itu iterative:
- Analyze: bundle analyzer + Lighthouse + RUM
- Optimize: code splitting, image format, memoization
- Verify: rollback improvements, measure real users
- Maintain: guardrails di CI (bundle size check, perf budget)
Investasi 1-2 minggu optimasi bisa naik 20-40% organic traffic — angka yang signifikan untuk ROI bisnis.