JavaScript Bundle Optimization
Heavy JS — main cause of poor INP and slow FCP for SPA. Browser must load, parse, and execute all JS before rendering. Bundle optimization — breaking into chunks, removing unused code, deferred loading.
Bundle analysis
# Vite — visualize via rollup-plugin-visualizer
npm install -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({
filename: 'dist/stats.html',
open: true,
gzipSize: true,
})
]
});
After build opens interactive bundle map. Look for:
- Large libraries (moment.js, lodash — often replaceable)
- Dependency duplication
- Libraries imported whole instead of needed function
Code Splitting — split by routes
// React Router v6 — lazy load pages
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
const ProductCatalog = lazy(() => import('./pages/ProductCatalog'));
const ProductDetail = lazy(() => import('./pages/ProductDetail'));
const Cart = lazy(() => import('./pages/Cart'));
const Checkout = lazy(() => import('./pages/Checkout'));
const router = createBrowserRouter([
{ path: '/catalog', element: <Suspense fallback={<PageSkeleton />}><ProductCatalog /></Suspense> },
{ path: '/products/:slug', element: <Suspense fallback={<PageSkeleton />}><ProductDetail /></Suspense> },
{ path: '/cart', element: <Suspense fallback={<PageSkeleton />}><Cart /></Suspense> },
{ path: '/checkout', element: <Suspense fallback={<PageSkeleton />}><Checkout /></Suspense> },
]);
Dynamic import of heavy components
// Editor, charts, maps — load only when needed
const RichTextEditor = lazy(() => import('./components/RichTextEditor'));
const Chart = lazy(() => import('./components/Chart'));
const YandexMap = lazy(() => import('./components/YandexMap'));
function ProductForm() {
const [showEditor, setShowEditor] = useState(false);
return (
<>
<button onClick={() => setShowEditor(true)}>
Add description
</button>
{showEditor && (
<Suspense fallback={<div>Loading editor...</div>}>
<RichTextEditor />
</Suspense>
)}
</>
);
}
Tree shaking — remove unused code
// Bad: import whole lodash (~70kB gzip)
import _ from 'lodash';
const sorted = _.sortBy(products, 'price');
// Good: import only needed function
import sortBy from 'lodash/sortBy';
const sorted = sortBy(products, 'price');
// Better: native JS
const sorted = [...products].sort((a, b) => a.price - b.price);
// date-fns instead of moment.js
import { format, addDays } from 'date-fns'; // tree-shakeable
import { ru } from 'date-fns/locale';
Replace heavy libraries
| Library | Replacement | Savings |
|---|---|---|
| moment.js (72kB) | date-fns (only needed functions) | ~60kB |
| lodash (70kB) | lodash-es + tree-shaking | ~50kB |
| axios (13kB) | native fetch | 13kB |
| jquery (87kB) | Native JS | 87kB |
| react-icons (all icons) | Only needed from @heroicons | 100–500kB |
Vite: manual chunk splitting
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// Vendor chunk — rarely changes, caches long
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-ui': ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
'vendor-query': ['@tanstack/react-query'],
'vendor-forms': ['react-hook-form', 'zod', '@hookform/resolvers'],
'vendor-charts': ['recharts'],
},
}
},
chunkSizeWarningLimit: 500,
}
});
Preload critical chunks
// Prefetch next page on link hover
function PrefetchLink({ to, children }) {
const prefetch = () => {
import(`./pages/${to}`).catch(() => {});
};
return (
<Link to={to} onMouseEnter={prefetch} onFocus={prefetch}>
{children}
</Link>
);
}
Bundle size metrics
Target values for e-commerce:
| Chunk | Goal (gzip) |
|---|---|
| Initial JS (critical path) | < 50 kB |
| React + React DOM | ~42 kB (fixed) |
| Catalog page | < 30 kB |
| Product card | < 20 kB |
| Cart/Checkout | < 40 kB |
Monitor size in CI
# .github/workflows/bundle-size.yml
- name: Check bundle size
run: |
npm run build
MAIN_JS=$(ls dist/assets/index-*.js | xargs stat -c%s | head -1)
if [ "$MAIN_JS" -gt 200000 ]; then
echo "Bundle too large: ${MAIN_JS} bytes"
exit 1
fi
Optimization time: 2–4 days: analysis, code splitting, library replacement.







