RA
RenovateAPIEngineering Hub
Frontend Development

Your Vite Bundle Is Probably 1.5MB for No Good Reason

Fix slow LCP and INP in React + Vite apps with route-level code splitting, manual Rollup chunks, and smarter icon imports. Real config included.

AAbhishek5 min read
Frontend Development5 min read

Your Vite Bundle Is Probably 1.5MB for No Good Reason

RENOVATEAPI ARCHITECTURAL SPEC
CANONICAL GUIDE

If you've run vite build on a React app that's grown past a dozen routes, you've probably seen the warning: a single index-[hash].js file north of 1.5MB. Vite's dev server is fast because it serves native ES modules over HMR, but that speed doesn't carry over to production automatically. Under the hood, vite build hands everything to Rollup, and Rollup will happily bundle your entire app — including the PDF generator you use on one settings page — into a chunk that ships on every single page load.

Three things cause most of the bloat:

  • A single giant vendor chunk that tanks Largest Contentful Paint (LCP) and Interaction to Next Paint (INP)
  • Shared utilities like lodash-es or icon sets getting duplicated across route chunks instead of sharing one
  • Heavy libraries (charting, rich text editors, PDF tools) loading on initial mount even when they only belong to one sub-route

None of this is exotic to fix. It's three changes, and they compound.

Split routes first, not components

The highest-leverage fix is also the simplest: only fetch code for a route when someone actually navigates there. React.lazy() with Suspense handles this at the router level.

import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// Eagerly loaded critical landing page
import HomePage from './pages/HomePage';

// Lazily loaded heavy secondary routes
const AnalyticsDashboard = lazy(() => import('./pages/AnalyticsDashboard'));
const SettingsModal = lazy(() => import('./pages/SettingsModal'));

export function AppRouter() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div className="loading-spinner">Loading interface...</div>}>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/analytics" element={<AnalyticsDashboard />} />
          <Route path="/settings" element={<SettingsModal />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Keep your true landing page eager — you want it in the initial bundle so there's no waterfall on first paint. Everything behind a click or a nav link is a lazy candidate. /analytics and /settings in the example above are the ones actually worth splitting; a dashboard with charts and a settings modal are exactly the kind of routes that drag in dependencies most users never touch.

This alone won't fix vendor duplication, though. If three lazy routes all import date-fns, Rollup can end up shipping three copies unless you also tell it how to group shared code.

Take manual control of vendor chunking

This is the part people skip, and it's the part with the actual leverage. Rollup's default heuristics for grouping node_modules are conservative — they're not wrong, exactly, but they're not tuned for your dependency graph. manualChunks lets you group by what actually matters: what changes rarely (React itself) versus what's route-specific (a charting library).

// 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: false, filename: 'stats.html', gzipSize: true, brotliSize: true }),
  ],
  build: {
    target: 'esnext',
    minify: 'esbuild',
    sourcemap: false,
    chunkSizeWarningLimit: 600,
    rollupOptions: {
      output: {
        manualChunks(id) {
          // Isolate core React runtime for maximum browser caching
          if (id.includes('node_modules/react') || id.includes('node_modules/react-dom')) {
            return 'react-core';
          }
          // Isolate UI/animation libraries
          if (id.includes('node_modules/framer-motion') || id.includes('node_modules/@radix-ui')) {
            return 'ui-vendor';
          }
          // Isolate data visualization & charting
          if (id.includes('node_modules/chart.js') || id.includes('node_modules/recharts')) {
            return 'charts-vendor';
          }
        },
      },
    },
  },
});

The react-core chunk is the one that matters most for repeat visitors. React and React DOM change on your release cadence, not on every deploy, so isolating them into their own chunk means returning users hit a browser cache instead of re-downloading the runtime every time you ship an unrelated bug fix. ui-vendor and charts-vendor do the same job for anything that's stable but heavy.

Don't stop at eyeballing the build output. Add rollup-plugin-visualizer and actually open stats.html after a build — it's the only reliable way to see which dependency is quietly bloating a chunk you assumed was small. Guessing at chunk boundaries without this is how you end up "optimizing" a 40KB module while a 300KB one sits untouched three folders over.

Stop importing entire icon libraries

This one's a single-line fix that's easy to miss, especially in a codebase with multiple contributors. Icon packages like lucide-react and react-icons ship hundreds of individual SVG components. Import the package wrong and you can pull the whole index into your bundle even if you only render three icons.

// BAD: Pulls entire icon index into bundle if barrel export optimization fails
import * as Icons from 'lucide-react';

// GOOD: Direct named imports allow bundlers to drop unreferenced symbols
import { CheckCircle, AlertTriangle, ArrowRight } from 'lucide-react';

The wildcard import isn't always fatal — some bundlers tree-shake barrel exports correctly — but relying on that is a bet you don't need to make. Named imports cost nothing and remove the risk entirely. If your linter doesn't already flag import * from large packages, it's worth adding that rule.

What this actually gets you

Optimization Problem it solves How
React.lazy() Massive entry bundle Dynamic import per route, wrapped in Suspense
manualChunks Poor browser cache reuse Isolate stable libraries into their own long-lived chunks
rollup-plugin-visualizer Blind bundle bloat Visual chunk map in stats.html after every build
Named icon imports Unused SVG bloat Explicit imports instead of barrel wildcards

Stacked together, these typically cut initial bundle downloads by 60-80%. That's not a marginal LCP tweak — it's the difference between a dashboard that feels instant and one that makes users stare at a spinner on a mid-range phone over 4G.

If you're only going to do one of these today, do the manual chunking. Route splitting gets more attention because it's the "obvious" fix, but a giant unsplit vendor chunk will keep hurting your cache hit rate even after you've lazy-loaded every route in the app.

RenovateAPI Engineering Suite

Accelerate your Frontend Development Modernization Roadmap

Need custom architecture auditing, automated OpenAPI contract generation, or zero-downtime microservice migration guidance for your engineering team?

Frequently Asked Questions

Why is my Vite production bundle so much bigger than expected?

Without manual chunking, Rollup either dumps everything into one entry file or creates a single generic vendor chunk. Heavy libraries like charting tools or rich-text editors get loaded on first paint even if the user never visits the page that needs them.

Does React.lazy() alone fix bundle size?

It fixes route-level bloat, but not vendor duplication. You still need manualChunks in vite.config.ts to stop shared dependencies like React itself or UI libraries from being duplicated across chunks or bundled with everything else.

What's the actual improvement from doing this?

Route splitting plus targeted manual chunks typically cuts initial bundle downloads by 60-80%, which shows up directly in LCP and INP scores on both mobile and desktop.

Weekly Engineering Dispatch

Subscribe to RenovateAPI

Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.

Discussion (2)

A
Alex Rivera
2 hours ago

Extremely helpful breakdown of the Strangler Fig pattern! We're currently refactoring a legacy Java monolith at work and the OpenAPI gateway routing tips saved us weeks of experimentation.

S
Sophia Chen
1 day ago

The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.

Suggested Related Articles