| license | Apache-2.0 |
| allowed-tools | Read,Write,Edit,Bash,Glob,Grep,WebSearch,WebFetch |
| name | vite-build-optimizer |
| description | Use when Vite dev startup is slow, HMR is not invalidating, the build emits chunks larger than expected, or you are authoring a Vite plugin. Triggers: "[plugin:..] failed", "Failed to resolve import", externalize warnings, dependency pre-bundling errors, lib mode + code splitting, ssr externals confusion, "Cannot find package" only in build, manualChunks tuning, optimizeDeps include/exclude tuning, rollup-plugin-visualizer review, --profile flag, configResolved/handleHotUpdate hook authoring. NOT for Webpack tuning, Turbopack-specific, Astro internals, or Bun bundler — those have their own conventions. |
| metadata | {"category":"Frontend & UI","tags":["vite","bundler","hmr","esbuild","rollup","build-tools","frontend"],"provenance":{"kind":"first-party","owners":["port-daddy"]},"pairs-with":[{"skill":"ideal-web-app-builder","reason":"The production web-app harness (React/Next/Vite apps with performance and release-engineering bars) whose Vite dev servers and builds this skill tunes."}],"io-contract":{"kind":"deliverable","consumes":["[Truncated]","[Truncated]"],"produces":["[Truncated]","[Truncated]"]}} |
Vite Build Optimizer
Vite is two tools wearing one config. In dev it's an esbuild-based ESM dev server with on-demand transforms; in prod it's a Rollup pipeline. Most Vite pain comes from confusing dev-mode behavior with prod-mode behavior.
When to use
- Dev startup over 3s on a small app, or HMR roundtrip over 200ms.
- HMR fails silently after editing a default-exported component.
- Production build has chunks >500KB you didn't ask for, or duplicate React/lodash copies.
- A package shows up in
optimizeDeps warnings (CJS interop edge cases).
- SSR build errors with "Cannot find package" while client build works.
- Authoring a plugin and need the right hook (transform vs handleHotUpdate vs configResolved).
Core capabilities
Dependency pre-bundling
Vite pre-bundles CommonJS deps to ESM and combines many small modules into one. This is dev-only.
export default defineConfig({
optimizeDeps: {
include: ['lodash-es/throttle', '@scope/pkg/dist/feature'],
exclude: ['@my/wasm-pkg'],
holdUntilCrawlEnd: true,
},
});
If the dev server thrashes (new dependencies optimized: … on every page load), one of your deps imports something dynamic Vite's scanner can't see — add it to include.
Server warmup
Tells the dev server to transform hot files before the first request hits.
server: {
warmup: {
clientFiles: ['./src/components/Layout.tsx', './src/routes/_root.tsx'],
},
}
Manual chunking
The function form lets you split by source path, not just module ID.
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('react') || id.includes('scheduler')) return 'react-vendor';
if (id.includes('@radix-ui')) return 'radix';
return 'vendor';
}
},
},
},
}
Don't manualChunks shared modules into the same chunk as a route — you'll undo route-level code splitting.
Resolve.dedupe — duplicate React bug
Symptoms: Invalid hook call in production only, or two copies of React in the bundle.
resolve: { dedupe: ['react', 'react-dom'] }
Common when a workspace package depends on react with a slightly different version.
SSR externals
For a Node-target SSR build, leave deps on disk; for an edge-target, bundle most things.
ssr: {
noExternal: ['@my-org/ui', /^@radix-ui\//],
external: ['sharp'],
}
Edge runtimes (Workers, Edge Functions) usually need noExternal: true on everything except a known node_modules allowlist, because there's no runtime require.
Plugin lifecycle
| Hook | Phase | What you do here |
|---|
config | before resolve | Mutate user config (add plugins, tweak optimizeDeps). |
configResolved | after resolve | Read final config (mode, command, root) — read-only. |
transform(code, id) | every module | Code transforms. Return { code, map }. |
handleHotUpdate({ file, server, modules }) | dev only | Custom HMR — return narrowed modules array, or [] to swallow the event. |
generateBundle | prod only | Inspect/mutate the final chunk graph. |
Profiling
node --cpu-prof node_modules/vite/bin/vite.js
vite build --profile
import { visualizer } from 'rollup-plugin-visualizer';
plugins: [visualizer({ filename: 'stats.html', gzipSize: true, brotliSize: true })]
Anti-patterns
Pre-bundling thrash from dynamic CJS imports
Symptom: Every page navigation logs new dependencies optimized: pkg/x. Cold reload appears to work but warm dev is sluggish.
Diagnosis: A dep does require(\./locales/${lang}`)or similar — Vite's scanner can't enumerate. Reload re-discovers, re-bundles, full page refresh. **Fix:** Enumerate the imports inoptimizeDeps.include (pkg/x/locales/en, pkg/x/locales/fr), or move the dep to optimizeDeps.exclude` if it ships ESM.
HMR breaking on default-exported components
Symptom: Editing a component triggers full reload instead of HMR.
Diagnosis: React Fast Refresh requires named function components for HMR to invalidate granularly. export default function Page() {…} works; export default () => … does not.
Fix: Name the function: export default function PageImpl() { … }; export { PageImpl as Page }.
Accidental Node-polyfills bloat
Symptom: Browser bundle includes buffer, events, process polyfills you never imported.
Diagnosis: A dep references process.env.NODE_ENV or Buffer and a polyfill plugin (vite-plugin-node-polyfills, etc.) is auto-injecting.
Fix: Remove the polyfill plugin and let the build fail loudly. Replace process.env.NODE_ENV with import.meta.env.MODE. For deps you can't fix, define a literal: define: { 'process.env.NODE_ENV': JSON.stringify(mode) }.
import.meta.env in code that runs in both SSR and client
Symptom: Client reads VITE_PUBLIC_KEY correctly; SSR sees undefined.
Diagnosis: import.meta.env is rewritten at build time per environment. SSR build resolves env vars from the SSR config; client from the client config. They're separate.
Fix: Pass env values explicitly into the SSR entry function rather than relying on import.meta.env inside library code.
manualChunks accidentally bundling app code into the vendor chunk
Symptom: Vendor chunk is 800KB; route chunks are 5KB.
Diagnosis: A manualChunks rule like id.includes('node_modules') || id.includes('utils') matches both vendors and the app's src/utils/.
Fix: Use absolute path tests (id.startsWith('/abs/path/node_modules/')) or call path.relative(root, id) first.
Missing source maps in production debugging
Symptom: Sentry/console shows minified column numbers.
Diagnosis: build.sourcemap defaults to false. Enabling it adds .map files but doesn't ship them to clients unless asked.
Fix: build.sourcemap: 'hidden' (writes maps, no //# sourceMappingURL= comment, upload separately).
Quality gates
Deterministic Audit
Before committing to a build-config change (or reviewing another agent's), write it as a
JSON plan matching schemas/vite-build-optimizer-plan.schema.json and run the
deterministic auditor:
node scripts/vite_build_optimizer_audit.mjs --input examples/sample-input.json
auditViteBuildOptimizer(plan) (in scripts/vite_build_optimizer_audit.mjs) turns this
skill's anti-patterns and Quality Gates into machine-checkable rules over structured
fields: a duplicate framework copy with no resolve.dedupe, pre-bundling thrash left
uncovered by optimizeDeps.include, HMR broken by anonymous default exports,
manualChunks rules matching app code by substring instead of absolute path, an
edge-target SSR build without noExternal, mutating config inside configResolved,
sourcemaps off (or shipped publicly) in production, oversized chunks, and unreviewed
visualizer output. It returns { pass, score, findings, recommendations }.
examples/sample-input.json is a bundle-size plan that clears every gate (pass: true).
Changes are tracked in CHANGELOG.md.
NOT for
- Webpack tuning → use a Webpack-specific skill.
- Turbopack (Next.js 15+) → different bundler, different config surface.
- Astro internals — Astro uses Vite but has its own collection/integrations layer. →
astro-islands-architect.
- Bun bundler →
Bun.build() API is separate.
- esbuild standalone → much smaller surface; use directly when Vite's plugin lifecycle is overkill.