INP JavaScript Bundles

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.

Measure gzipped size: Raw bundle size matters for parse time, but transfer size (gzipped) matters for download time. Both affect INP. Use gzip-size or your bundler's output to see compressed sizes. A 500KB raw bundle typically compresses to 150-180KB.
Bash -- Bundle analysis tools
# 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
Bash -- Find large dependencies quickly
# 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.

TypeScript -- React Router lazy loading
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.

TypeScript -- Dynamic component imports
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.

TypeScript -- Tree-shakeable imports
// 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';
JSON -- package.json sideEffects hint
{
  "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.

INP measures total delay: INP includes input delay (waiting for main thread), processing time (your event handler), and presentation delay (browser rendering the update). Yielding addresses input delay and processing time; keeping DOM updates small addresses presentation delay.
TypeScript -- Yield to the main thread
// 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
  • sideEffects configured in package.json for proper tree shaking
  • Long tasks broken up with scheduler.yield() or setTimeout

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