Reduce JavaScript Bundle Size to Fix INP
JavaScript is the primary bottleneck for Interaction to Next Paint (INP). Every kilobyte of JavaScript must be downloaded, parsed, compiled, and executed -- and during that process, the main thread is blocked from processing user interactions. A 1MB JavaScript bundle takes 2-4 seconds to process on a mid-range mobile device, during which every tap, click, and keystroke feels unresponsive.
INP measures the latency of the worst interaction during a page visit. Unlike FID (which only measured the first interaction), INP captures the full user experience. This means JavaScript that executes during scroll handlers, click events, or state updates all contribute to your INP score.
The Chrome User Experience Report shows that sites with JavaScript bundles over 500KB have a 3x higher rate of poor INP scores compared to sites under 200KB. This guide covers five techniques to reduce your bundle size and eliminate main thread blocking that causes poor INP.
Expected results
Following all steps in this guide typically produces these improvements:
Before
580ms
INP score (Poor) -- 1.2MB JavaScript bundle blocks main thread during user interactions
After
120ms
INP score (Good) -- code-split to 180KB initial bundle with deferred loading
Step-by-step fix
Analyze your bundle to find the biggest offenders
Before optimizing, you need to see exactly what is in your JavaScript bundle and how much each module contributes. Bundle analyzers create visual treemaps that reveal oversized dependencies, duplicate modules, and code that could be split or removed. This step frequently uncovers 100-300KB of unnecessary code.
gzip-size or your bundler's output to see compressed sizes. A 500KB raw bundle typically compresses to 150-180KB.
# Webpack Bundle Analyzer
npm install --save-dev webpack-bundle-analyzer
# Add to webpack.config.js:
# const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
# plugins: [new BundleAnalyzerPlugin()]
# Next.js Bundle Analyzer
npm install @next/bundle-analyzer
# next.config.js:
# const withBundleAnalyzer = require('@next/bundle-analyzer')({
# enabled: process.env.ANALYZE === 'true',
# });
# module.exports = withBundleAnalyzer(nextConfig);
ANALYZE=true npm run build
# Vite: rollup-plugin-visualizer
npm install --save-dev rollup-plugin-visualizer
# vite.config.js:
# import { visualizer } from 'rollup-plugin-visualizer';
# plugins: [visualizer({ open: true })]
# Source-map-explorer (works with any bundler)
npm install --save-dev source-map-explorer
npx source-map-explorer dist/assets/*.js
# Check installed package sizes
npx package-size lodash moment date-fns dayjs
# Common bloated packages and their lightweight alternatives:
# moment (300KB) -> date-fns (12KB) or dayjs (2KB)
# lodash (72KB) -> lodash-es (tree-shakeable) or native JS
# axios (13KB) -> fetch API (0KB, built-in)
# uuid (7KB) -> crypto.randomUUID() (0KB, built-in)
# classnames (1KB) -> clsx (0.5KB) or template literals (0KB)
# numeral (62KB) -> Intl.NumberFormat (0KB, built-in)
Implement route-based code splitting
Route-based code splitting loads only the JavaScript needed for the current page. Instead of downloading the entire application upfront, each route loads its own bundle. This is the highest-impact optimization for most applications because it directly reduces the initial JavaScript that must be parsed before the page becomes interactive.
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
// Each route loads its own JS bundle on demand
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
const router = createBrowserRouter([
{
path: '/',
element: (
<Suspense fallback={<PageSkeleton />}>
<Home />
</Suspense>
),
},
{
path: '/dashboard',
element: (
<Suspense fallback={<PageSkeleton />}>
<Dashboard />
</Suspense>
),
},
{
path: '/settings',
element: (
<Suspense fallback={<PageSkeleton />}>
<Settings />
</Suspense>
),
},
{
path: '/analytics',
element: (
<Suspense fallback={<PageSkeleton />}>
<Analytics />
</Suspense>
),
},
]);
export default function App() {
return <RouterProvider router={router} />;
}
Defer non-critical component loading with dynamic imports
Beyond route splitting, individual components that are below the fold, behind user interaction, or rarely used should be dynamically imported. This keeps the initial bundle small and loads additional code only when needed. Prioritize deferring modals, charts, rich text editors, and other heavy components.
import { lazy, Suspense, useState } from 'react';
// Heavy components loaded on demand
const RichTextEditor = lazy(() => import('./components/RichTextEditor'));
const ChartLibrary = lazy(() => import('./components/Charts'));
const PDFViewer = lazy(() => import('./components/PDFViewer'));
function Dashboard() {
const [showEditor, setShowEditor] = useState(false);
return (
<main>
{/* Critical content loads immediately */}
<h1>Dashboard</h1>
<MetricsSummary />
{/* Chart loads when scrolled into view */}
<Suspense fallback={<div className="chart-skeleton" />}>
<LazyVisible>
<ChartLibrary data={chartData} />
</LazyVisible>
</Suspense>
{/* Editor loads only on button click */}
<button onClick={() => setShowEditor(true)}>
Open Editor
</button>
{showEditor && (
<Suspense fallback={<div className="editor-skeleton" />}>
<RichTextEditor />
</Suspense>
)}
</main>
);
}
// Helper: renders children only when visible in viewport
function LazyVisible({ children }) {
const [isVisible, setIsVisible] = useState(false);
const ref = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return <div ref={ref}>{isVisible ? children : null}</div>;
}
Enable tree shaking and eliminate dead code
Tree shaking removes unused exports from your bundle. Modern bundlers (webpack 5, Vite/Rollup, esbuild) perform tree shaking automatically for ES modules, but it requires that your code and dependencies use ESM syntax (import/export) rather than CommonJS (require/module.exports). Libraries that are not tree-shakeable can add tens of kilobytes of unused code.
// BAD: imports entire library (72KB for lodash)
import _ from 'lodash';
const sorted = _.sortBy(items, 'name');
// GOOD: imports only what you use (4KB)
import sortBy from 'lodash-es/sortBy';
const sorted = sortBy(items, 'name');
// BEST: use native JavaScript (0KB)
const sorted = [...items].sort((a, b) =>
a.name.localeCompare(b.name)
);
// BAD: barrel imports pull in everything
import { Button, Modal, DatePicker } from '@ui/components';
// If you only use Button, Modal and DatePicker code
// is still bundled
// GOOD: direct imports enable tree shaking
import { Button } from '@ui/components/Button';
{
"name": "my-app",
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfills.ts"
]
}
// Setting sideEffects tells the bundler which files
// have side effects (code that runs on import).
// Files NOT listed can be safely tree-shaken.
// This is CRITICAL for libraries -- without it,
// the bundler assumes all imports have side effects
// and cannot remove unused code.
Break up long tasks to unblock the main thread
Even after reducing bundle size, the remaining JavaScript can still block INP if it runs in long, uninterrupted tasks. The browser cannot process user input while a JavaScript task is running. Breaking long tasks (50ms+) into smaller chunks using scheduler.yield() or setTimeout lets the browser handle interactions between chunks.
// The modern approach: scheduler.yield()
// Gives the browser a chance to handle pending interactions
async function processLargeDataset(items: Item[]) {
const results = [];
for (let i = 0; i < items.length; i++) {
results.push(processItem(items[i]));
// Yield every 5ms of work to keep INP responsive
if (i % 50 === 0) {
// scheduler.yield() (Chrome 129+) or fallback
if ('scheduler' in globalThis && 'yield' in scheduler) {
await scheduler.yield();
} else {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
return results;
}
// For event handlers specifically:
button.addEventListener('click', async () => {
// Show immediate visual feedback FIRST
button.classList.add('loading');
button.disabled = true;
// Yield so the browser can paint the loading state
await scheduler.yield?.() ??
new Promise(r => setTimeout(r, 0));
// Now do the heavy work
const result = await processExpensiveOperation();
// Update UI with results
updateDashboard(result);
button.classList.remove('loading');
button.disabled = false;
});
Quick checklist
- Bundle analyzed with webpack-bundle-analyzer or source-map-explorer
- Oversized dependencies replaced with lightweight alternatives
- Route-based code splitting implemented with lazy loading
- Heavy components dynamically imported behind user interaction
- ES module imports used for tree-shakeable dependencies
-
sideEffectsconfigured in package.json for proper tree shaking -
Long tasks broken up with
scheduler.yield()orsetTimeout
Frequently asked questions
Aim for under 200KB (compressed) for the initial JavaScript bundle. The total across all lazy-loaded chunks can be larger, but the initial load should be minimal. Sites with initial bundles under 200KB are 3x more likely to have good INP scores. For mobile-first applications, target under 100KB initial.
Yes, but HTTP/2 and HTTP/3 handle multiple concurrent requests efficiently. The performance benefit of smaller initial bundles far outweighs the overhead of additional requests. Modern bundlers also group small chunks together and use prefetching to load likely-needed chunks during idle time.
LCP is affected by JavaScript that blocks initial rendering -- the browser cannot paint the LCP element while parsing JavaScript. INP is affected by JavaScript that blocks the main thread during user interactions, which can happen at any point during the page lifecycle. Reducing bundle size helps both, but INP also requires breaking up long tasks and optimizing event handlers.
Web Workers run JavaScript off the main thread, which keeps it free for user interactions. They are excellent for CPU-intensive tasks like data processing, cryptography, or complex calculations. However, Workers cannot access the DOM, so they complement but do not replace main thread optimization. Use Workers for heavy computation; use yielding and code splitting for UI code.
Use the Chrome DevTools Performance panel with INP logging enabled. Record a session, interact with the page, and look for Long Tasks (yellow bars over 50ms) that coincide with your interactions. The Call Tree and Bottom-Up tabs show exactly which functions consumed the most time. The PerformanceObserver API with type 'long-animation-frame' provides programmatic access to this data.
Related resources
Complete INP Guide
Deep dive into Interaction to Next Paint -- thresholds, measurement, and optimization.
FixFix INP in Next.js
Next.js-specific INP optimizations with React Server Components.
FixFix INP in React
React-specific techniques for reducing interaction latency.
TutorialMeasure Core Web Vitals
Learn how to measure INP in lab and field data.