2026-08-11 · 10 min
Bundle Analyzer: Cara Saya Potong 40% JS dari Dashboard React yang Mulai Berat
Dashboard React yang saya audit bulan lalu untuk klien di Jakarta punya initial JS bundle 1.8MB setelah minifikasi — dan itu baru satu halaman. Dalam dua hari kerja, saya potong ke 1.06MB hanya dengan menerapkan teknik yang bisa dipelajari dalam artikel ini.
Kapan ini jadi masalah nyata
Bloat bundle biasanya tidak terasa sampai ada yang komplain. Di klien Jakarta itu, trigger-nya adalah laporan dari tim sales: “dashboard buka lama pakai wifi kantor, apalagi pakai hape.” Core Web Vitals di Lighthouse memperlihatkan TBT (Total Blocking Time) 900ms dan LCP di atas 4 detik di koneksi 4G simulasi. Semua metrik itu pointing ke satu penyebab: terlalu banyak JavaScript yang diparsing dan dieksekusi sebelum halaman bisa dipakai.
Reaksi pertama developer junior biasanya langsung ganti framework atau tambah CDN. Saya tidak melakukan itu. Langkah pertama adalah selalu: audit dulu apa yang ada di bundle sekarang.
Setup bundle analyzer
Proyek itu pakai Vite. Install satu package:
npm install --save-dev rollup-plugin-visualizer
Tambahkan ke vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
visualizer({
open: true, // otomatis buka browser setelah build
gzipSize: true, // tampilkan ukuran setelah gzip — ini yang relevan
brotliSize: true,
filename: 'dist/stats.html',
}),
],
});
Jalankan build sekali:
npm run build
Browser langsung membuka treemap interaktif. Kotak yang lebih besar berarti lebih berat. Di sinilah audit dimulai.
Untuk proyek webpack lama, plugin-nya berbeda tapi konsepnya sama:
npm install --save-dev webpack-bundle-analyzer
// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'bundle-report.html',
openAnalyzer: true,
}),
],
};
Membaca treemap dengan benar
Saat pertama buka visualizer, tampilannya terlihat overwhelming. Yang perlu dicari:
- Kotak raksasa yang tidak expected — library yang besar dan nama-nya tidak Anda kenali langsung. Ini sinyal dependency transitif.
- Library populer yang seharusnya kecil tapi terlihat besar —
moment(230KB+),lodash(70KB+ kalau tidak tree-shaken),date-fnslengkap. - Chart library atau rich-text editor — hampir selalu berat, dan hampir selalu hanya dipakai di satu halaman.
- Chunk yang tidak terpecah — seluruh aplikasi ada di satu chunk
index.jstanpa pemisahan per route.
Di klien Jakarta, saya menemukan:
recharts(470KB gzip) dimuat untuk semua halaman padahal chart hanya ada di 2 dari 11 halaman@fullcalendar/react(210KB) dimuat globallodashversi CommonJS masuk penuh (tidak tree-shaken) karena ada satu importimport _ from 'lodash'- Seluruh aplikasi ada di satu chunk — tidak ada code splitting sama sekali
Solusi 1: Route-level code splitting
Ini yang memberi dampak terbesar dengan effort paling kecil. Dari yang sebelumnya:
// Sebelum — semua halaman diimpor langsung
import DashboardPage from './pages/DashboardPage';
import ReportPage from './pages/ReportPage';
import CalendarPage from './pages/CalendarPage';
import SettingsPage from './pages/SettingsPage';
Ubah ke lazy import:
import { lazy, Suspense } from 'react';
// Setiap halaman jadi chunk terpisah
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const ReportPage = lazy(() => import('./pages/ReportPage'));
const CalendarPage = lazy(() => import('./pages/CalendarPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
// Bungkus router dengan Suspense
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/report" element={<ReportPage />} />
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Suspense>
);
}
Vite secara otomatis membuat chunk terpisah untuk tiap lazy import. Di Vite, chunk diberi nama berdasarkan file. Di webpack, tambahkan magic comment kalau ingin nama chunk eksplisit:
const ReportPage = lazy(
() => import(/* webpackChunkName: "report" */ './pages/ReportPage')
);
Setelah langkah ini, initial bundle turun dari 1.8MB ke sekitar 1.3MB — pengguna yang tidak pernah buka halaman Report atau Calendar tidak pernah memuat JavaScript untuk halaman itu.
Solusi 2: Lazy-load komponen berat
Chart library seperti recharts tidak perlu dimuat saat halaman pertama dibuka. Bahkan di halaman dashboard sekalipun, jika chart ada di bawah fold atau perlu klik tab dulu untuk tampil:
// components/SalesChart.tsx — bungkus lazy di tingkat komponen
import { lazy, Suspense } from 'react';
const RechartsComponent = lazy(() =>
import('./RechartsComponent').then(mod => ({ default: mod.SalesChart }))
);
export function SalesChartSection() {
return (
<Suspense fallback={<ChartSkeleton />}>
<RechartsComponent />
</Suspense>
);
}
Pola yang lebih sering saya pakai adalah conditional load — hanya muat chart saat komponen sudah masuk viewport:
import { useState, useEffect, useRef, lazy, Suspense } from 'react';
const HeavyChart = lazy(() => import('./HeavyChart'));
export function LazyChart({ data }: { data: ChartData[] }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.1 }
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return (
<div ref={ref} style={{ minHeight: 300 }}>
{visible ? (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart data={data} />
</Suspense>
) : (
<ChartSkeleton />
)}
</div>
);
}
Dengan ini, recharts hanya dimuat saat chart benar-benar mau dilihat pengguna.
Solusi 3: Ganti import yang mencegah tree-shaking
Ini perbaikan paling cepat, sering memberi dampak besar:
// Sebelum — import seluruh lodash (tidak tree-shakeable kalau pakai versi CJS)
import _ from 'lodash';
const result = _.debounce(fn, 300);
// Setelah — import hanya yang dipakai, dari lodash-es (ESM, tree-shakeable)
import { debounce } from 'lodash-es';
const result = debounce(fn, 300);
// Sebelum — moment.js, 230KB tidak bisa tree-shaken
import moment from 'moment';
const formatted = moment(date).format('DD MMM YYYY');
// Setelah — date-fns hanya impor fungsi yang dipakai
import { format } from 'date-fns';
import { id } from 'date-fns/locale';
const formatted = format(date, 'dd MMM yyyy', { locale: id });
Di klien Jakarta, penggantian lodash ke lodash-es plus date-fns memotong sekitar 200KB dari bundle setelah tree-shaking berjalan.
Solusi 4: Externalkan library yang tidak berubah (opsional)
Untuk library besar yang versinya sangat stabil, Anda bisa keluarkan dari bundle dan muat dari CDN. Ini hanya masuk akal kalau Anda yakin CDN tersebut reliable di target user (di Indonesia, cache CDN populer biasanya sudah di jaringan ISP besar):
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
external: ['react', 'react-dom'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
},
},
},
});
<!-- index.html — muat dari CDN -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
Saya jarang merekomendasikan ini untuk produk SaaS internal karena dependensi ke CDN eksternal menambah satu titik kegagalan. Tapi untuk landing page publik atau aplikasi yang target pengguna sudah punya cache browser dari site lain, ini efektif.
Hasil akhir dan cara mengukurnya
Setelah keempat langkah di atas:
| Metrik | Sebelum | Setelah |
|---|---|---|
| Initial JS bundle | 1.8MB | 1.06MB |
| Halaman dashboard (route utama) | 1.8MB | 680KB |
| TBT (4G simulasi) | 900ms | 310ms |
| LCP (4G simulasi) | 4.2s | 2.1s |
Pengukuran bukan dari Lighthouse saja — saya pakai npm run build lalu buka dist/stats.html untuk verifikasi struktur chunk, dan jalankan npx lighthouse terhadap production URL setelah deploy:
npx lighthouse https://dashboard.klien.com \
--only-categories=performance \
--throttling-method=simulate \
--preset=desktop \
--output=json \
--output-path=./lighthouse-report.json
Bundle analyzer adalah titik mulai audit, bukan akhirnya. Setelah struktur chunk diperbaiki, tetap ukur metrik nyata di production — karena ukuran bundle bukan satu-satunya faktor. Network waterfall, server response time, dan blocking third-party script sama berpengaruhnya.
Trade-off yang perlu diketahui
Code splitting bukan gratis. Ada beberapa ongkos:
- Waterfall request: semakin banyak chunk, semakin banyak request jaringan. Di HTTP/2 ini tidak terlalu masalah, tapi di koneksi lambat bisa terasa sebagai flash of loading skeleton di mana-mana.
- Kompleksitas Suspense boundary: kalau terlalu granular, Anda akan menulis banyak fallback dan berhadapan dengan nested Suspense yang perilakunya tidak selalu intuitif.
- Cache invalidation: setiap deploy ulang yang mengubah chunk akan invalidate cache browser untuk chunk itu. Strategi penamaan chunk yang konsisten (
[name]-[hash].js) membantu memaksimalkan cache hit untuk chunk yang tidak berubah.
Aturan saya: code split di level route selalu worth it. Lazy-load komponen berat (chart, editor, modal kompleks) hampir selalu worth it. Memecah lebih kecil dari itu perlu diukur kasus per kasus.
Kesimpulan
Bundle analyzer adalah alat diagnosis, bukan solusi. Buka visualizer, temukan sumber bloat terbesar, dan tangani yang paling berdampak dulu. Urutan prioritas yang saya pakai: route splitting dulu, lazy-load komponen berat kedua, perbaiki import yang mencegah tree-shaking ketiga. Di 90% kasus yang saya tangani, tiga langkah ini cukup untuk memotong 30-50% ukuran bundle tanpa refactor besar. Tidak perlu ganti framework.
Ditulis oleh Reza Pradipta