JavaScript is the most expensive resource on the web — byte for byte, a JavaScript file costs more than an equivalent image because it must be parsed and executed, not just decoded. A 500 KB JavaScript bundle can block the main thread for 2–4 seconds on a mid-range phone. Here's how to get it under 100 KB.
Step 1: Measure what you have
Before optimizing, audit your current bundle. Run:
npx @next/bundle-analyzer
Enable it in next.config.js with ANALYZE=true npm run build. The treemap shows which packages are largest and which pages include which modules.
Step 2: Remove unused dependencies
The most common bundle bloat sources:
| Package | Size | Replacement | Replacement size |
|---|---|---|---|
| moment.js | 67 KB | date-fns (tree-shaken) | 3–8 KB |
| lodash | 72 KB | lodash-es (tree-shaken) | 1–5 KB |
| axios | 14 KB | fetch (native) | 0 KB |
| react-icons (all) | 340 KB | Individual SVG imports | 0.5 KB/icon |
Step 3: Tree-shaking
Tree-shaking removes exports that are never imported. For it to work:
- Use named imports, not default imports from barrel files.
import { format } from 'date-fns'tree-shakes;import dateFns from 'date-fns'does not. - Avoid side-effect imports in utility modules. Files with
import 'some-polyfill'at the top are treated as having side effects and won't be tree-shaken. - Check the library's package.json for
sideEffects: false. Libraries that don't declare this are excluded from tree-shaking by Webpack/Turbopack.
Step 4: Code splitting
Next.js automatically splits code at the page level. For sub-page splitting, use dynamic imports:
const HeavyEditor = dynamic(() => import('./Editor'), { loading: () => <Spinner /> });
Good candidates for dynamic import: rich text editors, chart libraries, PDF viewers, map components, video players, and any component behind a tab or modal that isn't visible on initial load.
Step 5: Optimize third-party scripts
Use Next.js's <Script> component for third-party scripts:
strategy="lazyOnload"— loads after everything else, doesn't affect INP.strategy="afterInteractive"— loads after hydration, the right choice for most analytics.strategy="worker"— loads in a Web Worker (experimental), runs off the main thread entirely.
Step 6: Reduce React's footprint
- Move data-fetching components to Server Components. Server Components don't ship React runtime code for themselves.
- Replace React context with Zustand or Jotai for complex state. Context with many providers can cause excessive re-renders, indirectly inflating the cost of the JavaScript already in the bundle.
- Use React.memo() on expensive pure components that render frequently with the same props.
Before and after: real numbers
| Metric | Before | After |
|---|---|---|
| Homepage JS (gzipped) | 520 KB | 82 KB |
| Main thread blocking time | 3.2 s | 0.4 s |
| INP (75th percentile) | 480 ms | 95 ms |
| Lighthouse Performance | 61 | 94 |
The changes: replaced moment.js with date-fns, replaced lodash with native JS, moved the data table to a Server Component, and deferred the analytics scripts.