Skip to content

Fix: Vite Build Chunk Size Warning (Some Chunks Are Larger Than 500 kB)

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Vite's chunk size warning — why bundles exceed 500 kB, how to split code with dynamic imports and manualChunks, configure the chunk size limit, and optimize your Vite production build.

The Error

Running vite build completes but shows a warning:

vite v5.0.0 building for production...
✓ 1234 modules transformed.

dist/assets/index-Bh7kRCDa.js   1,823.45 kB │ gzip: 521.30 kB

(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit

The build succeeds, but the large bundle means slow initial page loads, especially on mobile connections.

Why This Happens

Vite uses Rollup for production builds. By default, Rollup bundles all imported modules into as few chunks as possible. The 500 kB warning fires when a single output file exceeds that size after minification (before gzip).

The warning is conservative on purpose. The Web Performance industry settled on 200 KB of JavaScript transferred (gzip-compressed) as the upper end of “good first paint” for mobile connections. A 500 kB minified chunk is roughly 150 kB gzipped — already at the edge of acceptable. Going past it consistently is how Largest Contentful Paint regressions creep in.

Common causes:

  • Large third-party libraries bundled into the main chunk — lodash, moment.js, chart libraries, UI component libraries included all at once.
  • No code splitting — every route’s code is bundled into a single file instead of being loaded on demand.
  • Importing entire libraries instead of specific functionsimport _ from 'lodash' pulls in all of lodash; import { debounce } from 'lodash' should tree-shake the rest, but not all libraries support tree-shaking.
  • Multiple large dependencies — even if individually small, several 100 kB libraries add up.
  • Polyfills duplicatedcore-js and Babel’s regenerator-runtime can each add 50-100 kB if the target browser list includes legacy browsers.
  • Source maps inlined into the chunkbuild.sourcemap: 'inline' embeds the source map inside the JS file, multiplying its size.
  • Side-effectful re-exports in barrel files — when a package marks all files as having side effects, Rollup cannot tree-shake unused exports.

Fix 1: Use Dynamic Imports for Route-Level Code Splitting

The most impactful change — load route components only when the user navigates to them:

React with React Router:

// Before — all routes bundled into one chunk
import HomePage from './pages/HomePage';
import DashboardPage from './pages/DashboardPage';
import SettingsPage from './pages/SettingsPage';
import ReportsPage from './pages/ReportsPage';

// After — each route is a separate chunk loaded on demand
import { lazy, Suspense } from 'react';

const HomePage = lazy(() => import('./pages/HomePage'));
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const ReportsPage = lazy(() => import('./pages/ReportsPage'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/dashboard" element={<DashboardPage />} />
        <Route path="/settings" element={<SettingsPage />} />
        <Route path="/reports" element={<ReportsPage />} />
      </Routes>
    </Suspense>
  );
}

Vue with Vue Router:

// router/index.js
import { createRouter, createWebHistory } from 'vue-router';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      component: () => import('./pages/HomePage.vue'), // Lazy loaded
    },
    {
      path: '/dashboard',
      component: () => import('./pages/DashboardPage.vue'), // Lazy loaded
    },
    {
      path: '/settings',
      component: () => import('./pages/SettingsPage.vue'), // Lazy loaded
    },
  ],
});

After adding dynamic imports, run vite build again — each route becomes a separate .js chunk.

Fix 2: Split Vendor Chunks with manualChunks

For third-party libraries that are used across multiple routes, split them into separate vendor chunks so they can be cached independently:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // Group large vendor libraries into named chunks
          'vendor-react': ['react', 'react-dom', 'react-router-dom'],
          'vendor-ui': ['@mui/material', '@emotion/react', '@emotion/styled'],
          'vendor-charts': ['recharts', 'd3'],
          'vendor-utils': ['lodash', 'date-fns', 'axios'],
        },
      },
    },
  },
});

Dynamic manualChunks function (more flexible — auto-groups all node_modules):

// vite.config.js
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // Put each package in its own chunk
            const packageName = id
              .toString()
              .split('node_modules/')[1]
              .split('/')[0]
              .replace('@', '');
            return `vendor-${packageName}`;
          }
        },
      },
    },
  },
});

Warning: Creating too many small chunks adds HTTP request overhead. Aim for a balance — group related libraries together. More than 20–30 chunks is usually counterproductive unless you are using HTTP/2.

Fix 3: Replace Heavy Libraries with Lighter Alternatives

Some libraries are inherently large. Replace them with lighter alternatives:

moment.js (329 kB) → date-fns or dayjs:

npm uninstall moment
npm install date-fns  # Tree-shakeable — only imports what you use
# or
npm install dayjs     # 2 kB, moment-compatible API
// Before
import moment from 'moment';
const formatted = moment(date).format('YYYY-MM-DD');

// After (date-fns — tree-shakeable)
import { format } from 'date-fns';
const formatted = format(date, 'yyyy-MM-dd');

// After (dayjs)
import dayjs from 'dayjs';
const formatted = dayjs(date).format('YYYY-MM-DD');

lodash (72 kB) — import specific functions:

// Before — imports all of lodash
import _ from 'lodash';
const result = _.debounce(fn, 300);

// After — imports only debounce (~2 kB)
import debounce from 'lodash/debounce';
// or
import { debounce } from 'lodash-es'; // ES module version — tree-shakeable

Replace heavy charting libraries with lighter ones:

HeavyLighter alternative
Chart.js (200 kB)uPlot (40 kB) for line charts
Highcharts (400 kB)Recharts (300 kB) or Victory
Three.js (600 kB)Load dynamically only on pages that use it

Fix 4: Analyze Bundle Contents

Before optimizing, identify what is actually inside the large chunk:

Use rollup-plugin-visualizer:

npm install --save-dev rollup-plugin-visualizer
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    react(),
    visualizer({
      filename: 'dist/stats.html',
      open: true,        // Opens the report in browser after build
      gzipSize: true,    // Shows gzip sizes
      brotliSize: true,
    }),
  ],
});

Run vite build — a visual treemap opens in your browser showing exactly which modules are largest.

Use vite-bundle-analyzer:

npx vite-bundle-analyzer

Identify the top contributors and focus optimization effort there.

Fix 5: Enable Build Optimizations in vite.config.js

Additional Vite build options to reduce bundle size:

// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    // Increase the warning threshold if the warning is acceptable
    chunkSizeWarningLimit: 1000, // kB — suppresses warning for chunks under 1 MB

    // Enable minification (enabled by default with esbuild)
    minify: 'esbuild', // Fast, or 'terser' for better compression

    // Enable CSS code splitting
    cssCodeSplit: true,

    // Generate source maps only for debugging (omit in prod to save space)
    sourcemap: false,

    rollupOptions: {
      output: {
        // Limit chunk file count to improve caching
        experimentalMinChunkSize: 20_000, // Merge chunks smaller than 20 kB
      },
    },
  },
});

Suppress the warning without fixing it (only appropriate if you’ve analyzed the bundle and the size is acceptable):

build: {
  chunkSizeWarningLimit: 1500, // Raise threshold to 1500 kB
},

Fix 6: Set a Bundle Budget and Enforce It in CI

A warning that everyone ignores is not a warning. Enforce a hard size limit in CI so regressions cannot land:

npm install --save-dev size-limit @size-limit/preset-app
// package.json
{
  "scripts": {
    "size": "size-limit",
    "build": "vite build && npm run size"
  },
  "size-limit": [
    {
      "name": "Main bundle",
      "path": "dist/assets/index-*.js",
      "limit": "180 KB",
      "gzip": true
    },
    {
      "name": "Vendor (React)",
      "path": "dist/assets/vendor-react-*.js",
      "limit": "50 KB",
      "gzip": true
    }
  ]
}

size-limit exits non-zero when a chunk exceeds the budget, blocking the merge. Tighten the budget gradually rather than setting an unreachable target — start from the current size, then reduce 5-10% per quarter as you eliminate fat.

bundlesize alternative for GitHub Actions:

# .github/workflows/bundle-size.yml
- name: Check bundle size
  uses: andresz1/size-limit-action@v1
  with:
    github_token: ${{ secrets.GITHUB_TOKEN }}

This posts a PR comment with size deltas, making regressions visible before they ship.

Fix 7: Lazy Load Heavy Components

Individual heavy components (rich text editors, code editors, maps, PDF viewers) should be loaded on demand:

// React — lazy load Monaco Editor (large)
import { lazy, Suspense, useState } from 'react';

const MonacoEditor = lazy(() => import('@monaco-editor/react'));

function CodeEditorPage() {
  const [showEditor, setShowEditor] = useState(false);

  return (
    <div>
      <button onClick={() => setShowEditor(true)}>Open Editor</button>
      {showEditor && (
        <Suspense fallback={<div>Loading editor...</div>}>
          <MonacoEditor language="javascript" value="// code here" />
        </Suspense>
      )}
    </div>
  );
}

Vue — lazy load on intersection (load when visible):

// components/HeavyChart.vue is only loaded when it enters the viewport
const HeavyChart = defineAsyncComponent(() => import('./components/HeavyChart.vue'));

In Production: Incident Lens

Bundle bloat is not a build-time error — it is a slow-burn production incident. The warning fires for months, nobody notices, and one day a marketing campaign drives traffic from a 3G mobile cohort and your Core Web Vitals dashboard turns red overnight.

Surface. Real User Monitoring (RUM) data shows Largest Contentful Paint creeping above the 2.5-second threshold. Time-to-Interactive degrades on slower devices. Bounce rates spike on mobile but not desktop. Google Search Console flags the site for “Page Experience” demotion. Synthetic monitoring (Lighthouse CI, WebPageTest) catches the regression in CI if you run it, but the lab numbers usually look fine on a fast machine — the field data is where the pain shows.

Blast radius. Every visitor on a cold cache pays the cost: a first-time visitor or any user whose browser cache expired. Mobile users on cellular connections are hit hardest. The bigger problem is compounding cache-key changes — if your manualChunks configuration causes hashes to invalidate on every deploy, returning users also pay the full download cost. SEO impact lags by weeks: Google updates its page experience signals on a delay, so by the time rankings drop you have multiple deploys to bisect.

Alerting. Monitor LCP at the p75 mark across mobile and desktop separately. Page when mobile p75 LCP > 2.5s for sustained 24 hours. Track total bytes transferred per route as a separate gauge — sudden jumps indicate a new dependency was added. Connect your CDN’s bandwidth-cost metric: an unexpected jump correlates with bundle bloat that is propagating to every page load.

Recovery. Forward-only. Identify the offending dependency with rollup-plugin-visualizer against the offending build hash, then refactor with dynamic imports for the heaviest contributors. Roll out behind a feature flag if the change is risky (route-level code splitting can break SSR or affect SEO). Purge the CDN cache after deploy so the lighter bundle reaches first-time visitors immediately. Verify the recovery with synthetic Lighthouse runs against the production URL, not just the dev preview.

Preventive. Run rollup-plugin-visualizer on every build and archive the report as a CI artifact, so you can diff treemaps between deploys. Add a hard size budget via size-limit or bundlesize in the merge gate. Set up Lighthouse CI to assert performance scores on PRs — a 5-point drop in performance score is enough to block. Audit dependencies quarterly: npx depcheck, npx bundle-phobia per package. The cheapest fix is rejecting a heavy dependency at PR review before it lands.

Still Not Working?

Check tree-shaking is working. Some libraries are not tree-shakeable because they use CommonJS exports. Check if an ES module version is available:

# Check if a package has an ES module entry point
cat node_modules/some-library/package.json | grep '"module"\|"exports"'

If the library does not have an ESM entry, it cannot be tree-shaken. Look for an alternative or import only what you need manually.

Check for barrel files in your own code. A barrel file (index.ts that re-exports everything) defeats tree-shaking:

// src/components/index.ts — barrel file
export { Button } from './Button';
export { Modal } from './Modal';
export { DataTable } from './DataTable'; // Heavy component

// Importing Button also pulls in DataTable if bundler cannot tree-shake
import { Button } from '../components';

// Fix — import directly
import { Button } from '../components/Button';

Verify your Vite version supports the optimization you need. Vite 5+ significantly improved code splitting. If you are on Vite 3 or 4, upgrade:

npm install vite@latest

Check for duplicate dependencies in node_modules. Run npm ls <package> to detect multiple versions of the same library. Two copies of React or lodash double the cost. Resolve by hoisting versions via overrides in package.json or resolutions in Yarn.

Check that NODE_ENV=production is set during the build. A common misconfiguration runs vite build with NODE_ENV=development, which disables minification entirely. Verify with vite build --mode production and check the output file sizes — minified JS is roughly 25-35% of the unminified size.

Check for CSS-in-JS runtime overhead. Libraries like Emotion or styled-components ship a runtime that adds 15-30 kB and prevents some static extraction. If CSS is your bloat source, consider Linaria, Vanilla Extract, or CSS Modules for static styling.

For related build and bundling issues, see Fix: Vite Failed to Resolve Import, Fix: webpack Module Not Found, Fix: webpack Bundle Size Too Large, and Fix: Vite HMR Connection Lost.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles