Code Splitting and Module Preload in Vite-Based SSGs
Astro, VitePress, SvelteKit's static adapter and many Vue- and React-based generators build their client JavaScript with Vite, which means Rollup does the bundling and splits code along dynamic import() boundaries. That is a good default, and it is also why a single careless import can put a 40 KB library on every page of a 400-page site: Vite follows static imports faithfully, and a static import at the top of a component used in the site header is, by definition, needed on every page.
This guide finds the JavaScript that loads everywhere, moves interaction-only code behind dynamic imports, groups vendor code deliberately with manualChunks, and keeps Vite's modulepreload hints aimed at what the page needs first. It is part of CSS and JavaScript Asset Optimization, and it complements JavaScript Hydration & Partial Rendering, which decides which components need JavaScript at all. The measurements come from a 410-page Astro 4.16 site with React islands for search, a feedback widget and a pricing calculator.
Prerequisites
- An Astro, VitePress or other Vite-based static build.
rollup-plugin-visualizerinstalled as a dev dependency.- Lighthouse and the DevTools Network panel for measuring what each template loads.
Step 1: Map the Chunks
Add the visualizer and build:
// astro.config.mjs
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
vite: {
plugins: [visualizer({ filename: 'stats.html', gzipSize: true, brotliSize: true, template: 'treemap' })]
}
});
The treemap showed the problem immediately. A SiteHeader React island (theme toggle, search button, mobile menu) statically imported fuse.js for search and @floating-ui/react for the menu popover. Because the header is on every page, both libraries were in the header's chunk, and every page loaded 142 KB of compressed JavaScript before any interaction.
| Chunk contents (docs page, first load) | Brotli |
|---|---|
| React + ReactDOM (shared) | 44 KB |
Header island + fuse.js + @floating-ui/react | 61 KB |
| Feedback widget island | 19 KB |
| Search index loader, analytics, misc | 18 KB |
Step 2: Move Interaction-Only Code Behind Dynamic Imports
Search was opened in 4% of sessions; the mobile menu only on narrow screens. Both libraries belong behind the interaction that needs them:
// SiteHeader.tsx
import { lazy, Suspense, useState } from 'react';
const SearchDialog = lazy(() => import('./SearchDialog')); // imports fuse.js inside
const MobileMenu = lazy(() => import('./MobileMenu')); // imports @floating-ui/react inside
export default function SiteHeader() {
const [searchOpen, setSearchOpen] = useState(false);
return (
<>
<button onClick={() => setSearchOpen(true)} onPointerEnter={() => import('./SearchDialog')}>Search</button>
{searchOpen && <Suspense fallback={null}><SearchDialog onClose={() => setSearchOpen(false)} /></Suspense>}
</>
);
}
The onPointerEnter warm-up starts the download when the pointer approaches the button, so on desktop the dialog is usually ready by the time the click lands. On touch devices, the first open waited about 180 ms on a throttled connection, which was judged acceptable for a feature used by one session in twenty-five.
Going further, the theme toggle and menu button did not need React at all. Rewriting them as a 1.2 KB vanilla module and deleting the header island removed React from pages that had no other islands — the change with the largest effect, covered in Replacing React Islands with Web Components.
Step 3: Group Vendor Code on Purpose
Automatic chunking sometimes produces awkward results: a charting library used only by the pricing calculator ended up in a shared chunk with a date library the feedback widget also used, so the feedback widget pulled in chart code. manualChunks fixes the grouping:
// astro.config.mjs (vite.build.rollupOptions)
output: {
manualChunks(id) {
if (id.includes('node_modules/chart.js')) return 'vendor-chart';
if (id.includes('node_modules/react') || id.includes('node_modules/scheduler')) return 'vendor-react';
}
}
Keep manual groups few and large. Dozens of tiny chunks cost more in request overhead and compression efficiency than they save; on HTTP/2 and HTTP/3 the sweet spot on this site was chunks of 10–50 KB compressed.
Step 4: Keep modulepreload Honest
Vite injects <link rel="modulepreload"> for every chunk an entry imports statically, so the browser fetches the whole graph in parallel instead of discovering imports one level at a time. That is exactly right for startup code. Two things make it wrong: a dynamic import whose chunk Vite still preloads because the chunk is also imported statically elsewhere, and a page whose preloads compete with its LCP image.
Check the <head> of built pages for preloads you did not expect:
grep -o 'rel="modulepreload" href="[^"]*"' dist/guide/install/index.html
After the changes above, a docs page preloaded two chunks (vendor-react, feedback widget) instead of seven. On pages with no islands, there were none. If a preload is legitimately needed only after interaction, Vite's build.modulePreload.resolveDependencies hook can filter it out; in Astro, fixing the import that caused it is usually cleaner.
Also give the LCP image priority over script preloads, with fetchpriority="high" on the image, as in Optimizing LCP on Astro with Priority Hints. Module preloads default to high priority in Chromium, so seven of them can delay a hero image by hundreds of milliseconds on a slow connection.
VitePress and Other Vite-Based Generators
The same techniques apply outside Astro, with small differences. VitePress builds a single-page app on top of static HTML, so after the first page, navigation loads per-page chunks rather than full documents; heavy components in .vitepress/theme land in the app's entry chunk and therefore on every page. Register rarely used components with defineAsyncComponent(() => import('./Playground.vue')) so they load only on pages that render them. SvelteKit with the static adapter splits per route automatically, and the same "shared layout imports" trap applies to +layout.svelte. In every case the visualizer treemap is the fastest way to see which module landed in which chunk and why.
Step 5: Guard It in CI
Initial JavaScript per template is the number to watch. Record it in CI from the build output rather than Lighthouse, because it is deterministic: for each representative page, sum the compressed size of every <script type="module"> and modulepreload referenced in the HTML. Fail the build if a template exceeds its budget, and post the diff on the pull request. This is how the header regression would have been caught when it was introduced; the workflow is described in Tracking Bundle Size per Pull Request.
Measured Impact
Lighthouse 12 mobile, median of five; field p75 from RUM over two weeks.
| Measure | Before | After |
|---|---|---|
| Initial JS, docs page (brotli) | 142 KB | 44 KB |
| Initial JS, page without islands | 105 KB | 1.2 KB |
| modulepreload hints on a docs page | 7 | 2 |
| Total Blocking Time, lab | 310 ms | 60 ms |
| LCP, lab | 2.3 s | 1.6 s |
| INP p75, field | 180 ms | 110 ms |
| First open of search (desktop, warm-up on hover) | 40 ms | 55 ms |
Pitfalls & Rollback
- Top-level imports in shared components. Anything in a header, footer or layout island lands on every page.
- Barrel files.
import { Button } from '@acme/ui'can pull the whole library if it is not tree-shakable; import from the component path. - Over-splitting. Hundreds of tiny chunks hurt compression and add request overhead.
- Lazy-loading above-the-fold interactivity. If a control is visible and used immediately, a lazy import adds visible delay; keep it in the initial chunk.
- Rollback: every change is a code or config change; reverting the commit restores the previous chunking on the next build.
Conclusion
Vite's splitting is only as good as the import graph you give it. Map the chunks, move interaction-only libraries behind dynamic imports with a warm-up, group vendor code deliberately, and make sure the preloads the page starts with are the ones it needs first. On the Astro site that took initial JavaScript from 142 KB to 44 KB on docs pages and to almost nothing on pages without islands, and moved LCP and INP with it.
FAQ
Does Vite split code automatically?
Yes. Every dynamic import creates a separate chunk, and code shared between entry points is extracted into common chunks. What Vite cannot know is which static imports you only need after an interaction, so heavy libraries imported at the top of shared components still land in the initial load.
What does modulepreload do?
It tells the browser to download and compile a JavaScript module before the code that imports it asks for it, avoiding a waterfall of nested imports. Vite injects modulepreload hints for the chunks each entry needs, which is helpful for startup chunks and wasteful for chunks only used after a click.
When should I use manualChunks?
When automatic splitting produces many tiny chunks or puts a rarely used dependency into a shared chunk. manualChunks lets you group vendor code deliberately, for example keeping a large charting library in its own chunk that only chart pages load.
How do I see what is in each chunk?
Add rollup-plugin-visualizer to the Vite configuration and build. It writes an interactive treemap of every chunk and module with sizes, including gzip and brotli estimates.
Related
- Parent: CSS and JavaScript Asset Optimization — the full asset pipeline.
- Astro Islands vs Full Hydration Performance — why islands keep bundles small.
- Deferring Hydration with client:visible in Astro — scheduling when chunks run.
- Preact vs React Islands Bundle Size — shrinking the vendor chunk itself.
- Auditing Unused Preloads — preload hygiene beyond scripts.