Vendor Chunk Isolation and Third-Party Management

Without vendor chunk isolation, every deployment β€” even a one-line CSS change β€” invalidates the browser cache for your entire JavaScript payload. For a 1.2 MB monolithic bundle, that means each of your users re-downloads 900 KB of React, React DOM, and their router on every push. The fix is architectural: separate third-party code into stable, content-hashed assets whose cache key only changes when a dependency version changes. Applied correctly, this produces a 77% improvement in cache-hit ratio on repeat visits and cuts Time to Interactive on warm loads from 2.8 s to 1.9 s.

This page covers vendor isolation as a tactical step within the broader Route-Based Code Splitting & Dynamic Import Strategies approach. That parent strategy defines when and where chunk boundaries live; vendor isolation defines what goes inside the shared chunks those boundaries expose.


How the chunk graph creates the problem

Bundlers build a directed acyclic graph (DAG) of every module import before deciding on output files. When a dependency appears in more than one entry point or async route, the bundler has to decide whether to copy it into each consumer chunk or extract it into a shared chunk. Without explicit configuration it often duplicates β€” each route ships its own copy of lodash or date-fns, and the browser parses the same bytecode repeatedly.

The diagram below shows the before/after at the chunk graph level.

Chunk graph: vendor duplication vs. vendor isolation Left side shows three route chunks each containing a copy of React and vendor libraries, inflating total payload. Right side shows the same routes sharing a single vendor chunk, reducing total payload. BEFORE β€” duplicated vendor code AFTER β€” isolated vendor chunk route-home.js react + react-dom (45 KB) vendor libs (120 KB) route-dashboard.js react + react-dom (45 KB) vendor libs (120 KB) route-settings.js react + react-dom (45 KB) Total transferred: ~780 KB vendor.js react + react-dom (45 KB) shared vendor libs (120 KB) route-home.js (8 KB) route-dashboard.js (12 KB) route-settings.js (6 KB) Total transferred: ~191 KB

The DAG traversal that produces this separation works in three stages.

  1. AST parsing and import mapping. The bundler resolves every static import and dynamic import() call, building an adjacency matrix of module relationships.
  2. Module promotion. Dependencies referenced by two or more distinct chunks are candidates for hoisting, provided they exceed the configured minSize threshold.
  3. Boundary enforcement. Route-level async splits are evaluated against the promoted vendor graph. Any route chunk that would have contained a promoted module gets a runtime reference instead.

Misaligned boundaries produce vendor fragmentation: identical packages compiled into multiple route-specific chunks, forcing the browser to parse and execute the same bytecode on every route transition.


Bundler-specific configuration

Webpack 5 β€” SplitChunksPlugin

// webpack.config.js β€” Webpack 5
module.exports = {
  optimization: {
    // Use deterministic IDs so chunk hashes only change when content changes
    moduleIds: 'deterministic',
    chunkIds: 'deterministic',
    splitChunks: {
      chunks: 'all',
      minSize: 20_000,
      maxSize: 400_000,
      cacheGroups: {
        // Priority 20: React core and scheduler into a dedicated chunk
        framework: {
          test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
          name: 'framework',
          priority: 20,
          reuseExistingChunk: true,
          enforce: true,
        },
        // Priority 10: everything else in node_modules
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor',
          priority: 10,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

The enforce: true flag on the framework group bypasses minSize and maxSize β€” React will always land in its own chunk regardless of size. reuseExistingChunk prevents the bundler from creating a second copy if the same module has already been extracted.

Vite 5+ / Rollup 4 β€” manualChunks

Configuring Vite manualChunks for vendor isolation covers the full range of options; the runnable baseline is:

// vite.config.ts β€” Vite 5+
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // Deterministic chunk naming: [name] comes from manualChunks return value
        chunkFileNames: 'assets/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash][extname]',
        manualChunks(id: string) {
          if (!id.includes('node_modules')) return undefined;

          // React core into a dedicated, long-lived chunk
          if (/\/node_modules\/(react|react-dom|scheduler)\//.test(id)) {
            return 'framework';
          }
          // Data-layer libraries that change on a different release cadence
          if (/\/node_modules\/(@tanstack|redux|@reduxjs|zustand|jotai)\//.test(id)) {
            return 'state';
          }
          // Everything else: one general vendor chunk
          return 'vendor';
        },
      },
    },
  },
});

Keeping the function pure and path-based is essential: any logic that reads the filesystem or calls Date.now() breaks determinism and causes hash churn on every build even when no dependency changed.


Framework integration

React β€” lazy and Suspense

Vendor isolation and dynamic import patterns for on-demand loading work together at the route level. Each lazy route is an async chunk boundary; the bundler will automatically reference the shared vendor chunk rather than duplicating it.

// router.tsx β€” React 18 + React Router 6
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import PageSpinner from './components/PageSpinner';

// Each import() becomes an async chunk. The vendor chunk is shared automatically.
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings  = lazy(() => import('./pages/Settings'));
const Profile   = lazy(() => import('./pages/Profile'));

const router = createBrowserRouter([
  {
    path: '/',
    element: (
      <Suspense fallback={<PageSpinner />}>
        <Dashboard />
      </Suspense>
    ),
  },
  {
    path: '/settings',
    element: (
      <Suspense fallback={<PageSpinner />}>
        <Settings />
      </Suspense>
    ),
  },
  {
    path: '/profile',
    element: (
      <Suspense fallback={<PageSpinner />}>
        <Profile />
      </Suspense>
    ),
  },
]);

export default function App() {
  return <RouterProvider router={router} />;
}

Each Suspense boundary is independent. If Settings fails to load, Dashboard is unaffected. Pair this with an ErrorBoundary around each Suspense to catch ChunkLoadError without crashing the whole app.

Vue 3 β€” defineAsyncComponent

// router/index.ts β€” Vue 3 + Vue Router 4 + Vite 5+
import { defineAsyncComponent } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';

// Vite's manualChunks groups these into the shared vendor chunk automatically
const Dashboard = defineAsyncComponent(() => import('@/views/Dashboard.vue'));
const Settings  = defineAsyncComponent({
  loader: () => import('@/views/Settings.vue'),
  // loadingComponent shown while the async chunk downloads
  loadingComponent: () => import('@/components/LoadingSpinner.vue'),
  delay: 200,
  timeout: 8000,
});

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/',          component: Dashboard },
    { path: '/settings',  component: Settings  },
  ],
});

The delay option prevents a loading flash for fast connections while still providing feedback on slow ones.

Next.js 15 App Router

// next.config.js β€” Next.js 15
/** @type {import('next').NextConfig} */
module.exports = {
  // Packages listed here are required at runtime on the Node.js server,
  // NOT bundled into the server bundle. Use for Node-only dependencies.
  serverExternalPackages: ['sharp', 'canvas'],
  webpack(config) {
    // Isolate React into a dedicated client vendor chunk
    config.optimization.splitChunks.cacheGroups = {
      ...config.optimization.splitChunks.cacheGroups,
      framework: {
        test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
        name: 'framework',
        priority: 30,
        reuseExistingChunk: true,
        enforce: true,
      },
    };
    return config;
  },
};

Next.js already splits vendor code in its default configuration; the custom cacheGroup elevates React to a guaranteed dedicated chunk, which stabilises the framework hash even when other dependencies change.

SSR/SSG chunk graph parity

Server-side rendering frameworks must produce matching chunk boundaries on client and server builds. A divergence β€” for example, the server inlining a package that the client treats as a shared vendor chunk β€” causes hydration hash mismatches and layout shifts. Three rules prevent this:

  1. Use modulepreload links. During SSR, inject <link rel="modulepreload" href="/assets/vendor-[hash].js"> so the browser fetches vendor code in parallel with the HTML document, eliminating a waterfall before hydration.
  2. Align noExternal / serverExternalPackages. Packages with browser globals must be compiled into the client vendor chunk (not the server bundle). Mark them appropriately in Nuxt’s ssr.noExternal or Next’s serverExternalPackages.
  3. Run the same hash check in both CI builds. If the client reports vendor-abc123.js but the server-rendered HTML references vendor-def456.js, the mismatch surfaces immediately in CI rather than in production.

Quantified impact

  • Cache-hit ratio (minor release): 12% β†’ 89% (+77 pp). App code changes on every push; vendor code changes only when a dependency version bumps. Separating them ensures the vast majority of return visits skip the vendor download entirely.
  • Time to Interactive (repeat visit): 2.8 s β†’ 1.9 s (βˆ’32%). The browser reads the vendor chunk from disk cache and only parses the new application delta.
  • Main-thread parse time: 410 ms β†’ 185 ms (βˆ’55%). Smaller per-route payloads require less sequential parsing; HTTP/2 multiplexing delivers them in parallel.
  • Initial network requests: 3 β†’ 5 (+2). A modest increase that HTTP/2 handles without a waterfall penalty, provided modulepreload hints are in place.
  • Vendor payload vs. monolithic bundle: 165 KB (framework + vendor chunks, gzip) vs. 890 KB (monolithic, gzip).

Common pitfalls

Vendor hash churn on every build

Root cause: non-deterministic module IDs. If Webpack assigns numeric IDs based on module insertion order, adding or removing any import reshuffles IDs and invalidates every chunk hash β€” including the vendor chunk.

Diagnostic signal: vendor-[hash].js has a different hash in two consecutive builds where no dependency changed. Check with git diff dist/assets/.

Fix: set optimization.moduleIds: 'deterministic' and optimization.chunkIds: 'deterministic' in Webpack 5. In Vite, the Rollup default is already deterministic; verify the manualChunks function is pure.


Vendor chunk fragmentation

Root cause: an overly granular manualChunks function returns a unique key per sub-package. Each key becomes its own output file, resulting in 20+ vendor chunks that the browser must waterfall.

Diagnostic signal: dist/assets/ contains more than six .js files with vendor in the name; DevTools Network panel shows a long sequential chain of script requests.

Fix: collapse fine-grained keys into three to four groups (framework, state, ui-components, vendor). Re-measure with bundle analysis tools like rollup-plugin-visualizer or webpack-bundle-analyzer before shipping.


Async route duplicating vendor modules

Root cause: splitChunks.chunks is set to 'async' but a dynamically imported route imports a dependency that is also present in the initial (synchronous) chunk. The bundler cannot hoist it because the initial chunk is not subject to async-mode splitting.

Diagnostic signal: webpack-bundle-analyzer shows the same package appearing in both the vendor chunk and a route chunk.

Fix: change chunks to 'all' so both initial and async chunks participate in the same promotion logic. If reducing initial load is the goal, use prefetch and preload strategies for critical async routes instead of restricting chunk promotion.


SSR hydration hash mismatch

Root cause: client and server builds resolve different vendor chunk boundaries, producing mismatched content hashes in the server-rendered <script> tags and the actual files on disk.

Diagnostic signal: console error Hydration failed because the server rendered HTML didn't match the client paired with a 404 on a .js asset in the Network panel.

Fix: run client and server builds from the same config root. Confirm manualChunks or cacheGroups configuration is shared across both pipelines and that no dependency is listed in both serverExternalPackages and the client vendor group.


Verification workflow

1. Bundle stats β€” visualise chunk boundaries

# Webpack 5 β€” generate stats.json, then open in webpack-bundle-analyzer
npx webpack --json > stats.json
npx webpack-bundle-analyzer stats.json

# Vite 5+ β€” rollup-plugin-visualizer generates an HTML report
# Add to vite.config.ts:
#   import { visualizer } from 'rollup-plugin-visualizer';
#   plugins: [visualizer({ open: true, gzipSize: true })]
npm run build

Open the report and confirm that react, react-dom, and scheduler appear inside a single framework chunk, not scattered across route chunks.

2. DevTools Network panel

Load the app in Chrome with cache disabled (Cmd/Ctrl+Shift+R), then filter by JS. You should see the framework and vendor chunks arrive on the initial load. Navigate between routes: subsequent route transitions should show only the new route chunk downloading β€” the vendor chunks should return (disk cache) or (memory cache).

3. Content-hash stability check

# Build twice without changing any source file
npm run build && ls dist/assets/*.js | sort > /tmp/hashes-1.txt
npm run build && ls dist/assets/*.js | sort > /tmp/hashes-2.txt
diff /tmp/hashes-1.txt /tmp/hashes-2.txt
# Expected: no diff. Any change indicates non-determinism.

4. CI enforcement

Gate vendor chunk count and size in every pull request:

# .github/workflows/bundle-audit.yml β€” Webpack 5 / Vite 5+
name: Vendor Chunk Gate
on: [pull_request]
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
      - name: Enforce vendor chunk limits
        run: |
          node -e "
            const fs = require('fs');
            const dir = 'dist/assets';
            const files = fs.readdirSync(dir).filter(f => f.endsWith('.js'));
            const vendorFiles = files.filter(f =>
              f.startsWith('vendor') || f.startsWith('framework') || f.startsWith('state')
            );
            const totalBytes = vendorFiles.reduce(
              (sum, f) => sum + fs.statSync(dir + '/' + f).size, 0
            );
            if (vendorFiles.length > 4) {
              process.exitCode = 1;
              console.error('Vendor fragmentation: ' + vendorFiles.length + ' chunks (max 4)');
              console.error(vendorFiles.join(', '));
            }
            if (totalBytes > 450_000) {
              process.exitCode = 1;
              console.error('Vendor payload: ' + Math.round(totalBytes / 1024) + ' KB (max 450 KB)');
            }
            if (!process.exitCode) {
              console.log('Vendor OK: ' + vendorFiles.length + ' chunks, ' + Math.round(totalBytes / 1024) + ' KB');
            }
          "

Pair this check with Lighthouse CI to catch regressions in Time to Interactive:

// lighthouserc.json
{
  "ci": {
    "collect": { "url": ["http://localhost:3000"], "numberOfRuns": 2 },
    "assert": {
      "assertions": {
        "resource-summary:script:count": ["error", { "max": 8 }],
        "resource-summary:script:size": ["warn", { "max": 450000 }],
        "interactive": ["error", { "maxNumericValue": 2500 }]
      }
    }
  }
}

Frequently asked questions

How many vendor chunks should an SPA have?

Aim for two to four: a framework chunk (React or Vue core), a general vendor chunk, and optionally a state-management or UI-component chunk. Beyond four chunks, waterfall request penalties start to exceed the cache-longevity benefit, especially for users on HTTP/1.1 connections or with high-latency networks.

Why does my vendor chunk hash change on every build even though I didn’t update dependencies?

The most common cause is module ID instability. Webpack 5’s deterministic module IDs (optimization.moduleIds: 'deterministic') and a pure manualChunks function in Vite both guarantee that the same input always produces the same hash. Non-deterministic causes include: importing the environment variable Date.now() inside manualChunks, using dynamic require.context without stable ordering, or a Babel plugin that injects random IDs.

Should I isolate dev-only dependencies such as testing libraries?

No. Dev dependencies must not appear in production builds at all. Vendor isolation applies only to runtime dependencies shipped to the browser. A CI check that scans output assets for @testing-library, jest, or vitest strings is a reliable guard against accidental test-code inclusion.