Optimizing Chunk Splitting for Remote Apps #

A Vite federation remote that splits its bundle badly will either ship one giant remoteEntry chunk that blocks the host, or fragment into dozens of tiny files that flood the network and stall time-to-interactive — and in both cases it often re-bundles React, the router, and other shared libraries that the host already loaded.

This guide shows how to take control of chunk boundaries in a Vite remote using Rollup’s manualChunks, keep shared vendors out of the remote payload, and preload the chunks that the first render actually needs.

The problem in one sentence #

Default Rollup chunking inside a @originjs/vite-plugin-federation remote tends to over-split application code into many tiny files while under-splitting (or duplicating) vendor code, so the host pays to download dependencies it already has and the remote mounts slower than it should.

Naive vs optimized remote chunk graph The left column shows host and remote each bundling their own React copy; the right column shows both pointing at one shared vendor-react chunk. Naive splitting Optimized splitting Host bundle react (copy A) Remote entry react (copy B) React downloaded twice Host bundle Remote entry vendor-react One shared instance
Left: host and remote each carry their own React. Right: both resolve a single shared vendor chunk at runtime.

Prerequisites #

This guide assumes a working Vite remote. Specifically:

A baseline understanding of how the runtime resolves shared modules helps here — see managing shared dependencies at runtime for the mechanics this guide builds on.

Step 1: Declare shared deps so they leave the remote payload #

Before touching manualChunks, make sure every dependency you want shared is listed in the federation plugin’s shared block. Anything not listed there gets bundled straight into the remote, no matter how you chunk it.

// vite.config.ts
import { defineConfig } from 'vite';
import federation from '@originjs/vite-plugin-federation';

export default defineConfig({
  plugins: [
    federation({
      name: 'checkout_remote',
      filename: 'remoteEntry.js',
      exposes: {
        './CheckoutFlow': './src/checkout/entry.ts',
      },
      shared: {
        react: { requiredVersion: '^18.2.0' },
        'react-dom': { requiredVersion: '^18.2.0' },
        'react-router-dom': { requiredVersion: '^6.22.0' },
      },
    }),
  ],
  build: { target: 'esnext' },
});

With this in place the federation runtime negotiates a single instance of each shared library with the host instead of loading the remote’s own copy. This is the single most important defense against duplicated vendors, and it pairs directly with the broader goal of avoiding bundle duplication.

Step 2: Split vendor code with manualChunks #

Now isolate your non-shared third-party code into stable vendor chunks so they cache independently of your app code. Use the function form of manualChunks for fine control.

// vite.config.ts — build.rollupOptions
build: {
  target: 'esnext',
  rollupOptions: {
    output: {
      manualChunks(id) {
        if (!id.includes('node_modules')) return;

        // Group charting + date libs together — changed rarely, used widely.
        if (/[\\/]node_modules[\\/](recharts|d3-.*|date-fns)[\\/]/.test(id)) {
          return 'vendor-viz';
        }
        // Everything else from node_modules → one general vendor chunk.
        return 'vendor';
      },
      chunkFileNames: 'assets/[name]-[hash].js',
    },
  },
},

Note what is absent: there is no vendor-react rule. React, React-DOM, and the router are declared as shared, so they are resolved at runtime and must not be forced into a local chunk. Returning a chunk name for them here would re-bundle them and defeat sharing.

Step 3: Split exposed feature code from internal modules #

Keep each exposed feature in its own chunk so the host loads only what it mounts. Extend the same manualChunks function to handle your src/ tree.

manualChunks(id) {
  if (id.includes('node_modules')) {
    if (/[\\/](recharts|d3-.*|date-fns)[\\/]/.test(id)) return 'vendor-viz';
    return 'vendor';
  }
  // One chunk per exposed feature boundary.
  if (id.includes('/src/checkout/')) return 'feature-checkout';
  if (id.includes('/src/shared-ui/')) return 'feature-shared-ui';
  // Leave the rest to Rollup's default per-entry splitting.
},

The goal is boundary-aligned chunks, not one chunk per file. Group by the directory that maps to a deployable feature, and let Rollup handle the remaining smaller modules. Make sure your exposed entry (./src/checkout/entry.ts) imports its heavy children dynamically so they land in feature-checkout rather than the eagerly-loaded remoteEntry.js.

Step 4: Keep the exposed entry light with dynamic imports #

manualChunks decides where a module lands, but it cannot move code out of remoteEntry.js if your exposed entry statically imports it. Anything reachable through a top-level import in the exposed module is treated as eager and is parsed before the remote can mount. The fix is to make the exposed entry a thin shell that lazily pulls in its heavy children.

// src/checkout/entry.ts — the exposed module
import { lazy, Suspense } from 'react';
import type { ReactElement } from 'react';

// Heavy panels (payment SDK, address validation, order summary)
// load on demand instead of inflating remoteEntry.js.
const CheckoutFlow = lazy(() => import('./CheckoutFlow'));

export default function CheckoutEntry(): ReactElement {
  return (
    <Suspense fallback={<div data-mf="checkout-skeleton" />}>
      <CheckoutFlow />
    </Suspense>
  );
}

With this shape, remoteEntry.js contains only the wrapper and the Suspense boundary; the real work moves into feature-checkout. The host can negotiate shared deps and render a skeleton immediately, then stream the feature chunk. This is the same instinct behind dynamically loading remote modules at runtime — defer everything not needed for the very first paint.

Step 5: Preload the critical chunks #

Once chunks are right-sized, eliminate the request waterfall on first mount. When the host requests remoteEntry.js, the browser does not yet know it will also need feature-checkout and vendor. Emit modulepreload hints so those start downloading in parallel.

// vite.config.ts
build: {
  modulePreload: { polyfill: true },
  rollupOptions: {
    output: {
      // ...manualChunks + chunkFileNames from above
    },
  },
},

For the host side, add an explicit preload once you know the critical chunk names from your build manifest:

<!-- host index.html or injected at runtime -->
<link rel="modulepreload" href="https://cdn.example.com/checkout/assets/vendor-[hash].js" />
<link rel="modulepreload" href="https://cdn.example.com/checkout/assets/feature-checkout-[hash].js" />

Read the hashed filenames from the remote’s dist/.vite/manifest.json (enable with build.manifest: true) so the host always references the current build rather than a stale hash.

Step 6: Set guardrails so chunking stays optimal #

Chunk graphs drift. A new dependency, a careless static import, or a refactor that merges two feature directories can quietly reintroduce the exact problems you just fixed. Two cheap guardrails keep the split honest across future commits.

First, ask Rollup to warn when a chunk crosses a size budget, and right-size the inlining threshold so trivially small modules get folded into their parent instead of becoming standalone files:

// vite.config.ts — build
build: {
  target: 'esnext',
  chunkSizeWarningLimit: 250, // KB (pre-gzip); flags accidental bloat
  rollupOptions: {
    output: {
      // manualChunks + chunkFileNames from earlier steps
      experimentalMinChunkSize: 10_000, // merge sub-10 KB chunks automatically
    },
  },
},

experimentalMinChunkSize tells Rollup to coalesce chunks below the threshold into a related chunk, which is the surgical answer to the “swarm of tiny files” failure mode without hand-tuning every boundary.

Second, fail the build in CI when the eager surface grows. A short assertion against the manifest catches a remoteEntry.js that has crept past its budget — usually a sign a heavy import escaped the lazy boundary from Step 4:

# ci/check-remote-entry-size.sh
SIZE=$(gzip -c dist/assets/remoteEntry*.js | wc -c)
echo "remoteEntry gzipped: ${SIZE} bytes"
test "$SIZE" -lt 30000 || { echo "remoteEntry too large"; exit 1; }

Wire this into the same pipeline that ships the remote so a regression is caught before it reaches the host. Sizing decisions like these tie back to measuring bundle size impact of shared dependencies, where the budgets themselves are derived.

Verification: read the chunk graph #

Confirm the split worked by inspecting the actual output, not just the config.

// vite.config.ts — add to plugins
import { visualizer } from 'rollup-plugin-visualizer';

plugins: [
  /* federation(...) */
  visualizer({ filename: 'dist/stats.html', gzipSize: true, template: 'treemap' }),
],

Build and open the report:

npm run build && open dist/stats.html

Check three things in the treemap and in dist/assets/:

  1. No React in the remote. Search the stats for react and react-dom. They should be absent from every chunk — present only as shared-module references in remoteEntry.js, not as actual code.
  2. Vendor chunks are distinct from feature chunks. You should see vendor, vendor-viz, feature-checkout, and so on as separate files, each with its own hash.
  3. No single oversized chunk and no swarm of tiny ones. Aim for feature chunks in the tens-of-KB range gzipped, not one 400 KB blob and not 60 files under 2 KB each.

Then load the host with DevTools Network open and cache disabled. You should see react requested exactly once across host and remote combined, and vendor plus the relevant feature-* chunk fetching in parallel rather than chaining off remoteEntry.js.

Troubleshooting #

Shared dependency duplicated per remote #

Symptom: DevTools shows two react-dom responses, or you hit “Invalid hook call” / multiple React instances at runtime.

Diagnosis: The library is missing from the shared block in one project, or the requiredVersion ranges across host and remotes do not overlap, so the runtime falls back to bundling a local copy.

Fix: List the library in shared for every host and remote, and align the requiredVersion ranges so they intersect. Never add a manualChunks rule that names a shared library — that re-bundles it. Confirm the chunk graph (verification step 1) no longer contains the library’s source.

Too many tiny chunks #

Symptom: dist/assets/ holds dozens of sub-2 KB JS files and DevTools shows a long column of small parallel requests that drag out time-to-interactive over HTTP/2.

Diagnosis: Either deep per-file dynamic imports or a manualChunks function that returns a unique name per module. Each tiny chunk costs a request plus decompression overhead.

Fix: Coarsen the grouping — return one chunk name per feature directory, not per file (Step 3). Remove any return id style logic. Consolidate rarely-changing third-party libs into a single vendor chunk and reserve separate vendor chunks only for genuinely large, independently-versioned libraries.

Circular chunk references #

Symptom: Rollup warns about a circular dependency between emitted chunks, or a chunk executes before a sibling it depends on, throwing Cannot access '...' before initialization.

Diagnosis: Your manualChunks rules cut a module graph that has cycles — for example splitting feature-checkout and feature-shared-ui when checkout imports shared-ui and shared-ui imports back into checkout.

Fix: Merge the mutually-dependent directories into one chunk, or break the import cycle in source (extract the shared piece into a third leaf module that both import). Keep manualChunks boundaries aligned with one-directional dependency edges.

Slow time-to-interactive despite small chunks #

Symptom: Individual chunks are reasonably sized, but the remote still mounts slowly and LCP/TTI lag.

Diagnosis: A request waterfall — the browser discovers feature-checkout only after remoteEntry.js parses, then discovers vendor only after that. Each hop adds a round trip.

Fix: Add modulepreload hints for the critical chunks (Step 4) so they download in parallel with remoteEntry.js. Verify in the Network panel that the critical chunks now start at roughly the same time instead of stair-stepping, and keep eager-loaded code in remoteEntry.js minimal so the mount path is short.