How I Made Vite's First Paint 10x Slower by Trying to Optimize It
Before chunk splitting
After chunk splitting
Some optimizations don't need to be done. Vite's default configuration is the best practice.
Foreword
Let me show you a real data comparison.
This is the first-screen loading situation of the same Vue3 + Vite project before and after modifying the chunk splitting configuration:
Before chunk splitting (Vite default configuration):
| Name | Status | Type | Initiator | Size | Time |
|---|---|---|---|---|---|
| index-712a6fc0.js | 200 | script | login:16 | 340 kB | 319 ms |
| index-37f01f92.css | 200 | stylesheet | login:17 | 328 kB | 327 ms |
| login-c327e251.js | 200 | script | index-712a6fc0.js:5 | 9.7 kB | 51 ms |
| login-9a94892b.css | 200 | stylesheet | index-712a6fc0.js:5 | 1.2 kB | 49 ms |
| login | 200 | document | /login | 1.0 kB | 39 ms |
| 1710832132bj.webp | 200 | webp | login-9a94892b.css | 69.4 kB | 290 ms |
Total first-screen time: ~800ms
After chunk splitting (manual manualChunks configuration):
| Name | Status | Type | Initiator | Size | Time |
|---|---|---|---|---|---|
| vendor-a32a4159.js | 200 | script | login:17 | 1,968 kB | 6.56 s |
| element-plus-ff15f869.css | 200 | stylesheet | login:21 | 327 kB | 507 ms |
| element-plus-00e56c40.js | 200 | script | login:19 | 276 kB | 584 ms |
| vue-ecosystem-ec9ba455.js | 200 | script | login:18 | 102 kB | 215 ms |
| vendor-501cf061.css | 200 | stylesheet | login:20 | 15.1 kB | 155 ms |
| index-4d3f05e7.js | 200 | script | login:16 | 13.3 kB | 84 ms |
Total first-screen time: ~7.5 seconds
Same project, same network environment, just changed the manualChunks configuration in vite.config.ts, first-screen time went from under 1 second to 7.5 seconds, nearly 10 times slower.
That afternoon me:
This article is not about teaching you how to configure Vite, but about sharing a realization: Vite's default configuration is much smarter than you think. Before optimizing, first confirm that a problem actually exists.
What exactly did I do?
Let me reconstruct my naive operation at the time.
The project uses Vue3 + Vite, first-screen load was about 800ms, which is actually quite fast. But I always felt:
- "Splitting third-party libraries into a separate vendor can leverage browser caching"
- "node_modules is so big, it shouldn't be mixed with business code"
- "Manually controlling chunk splitting must be better than automatic"
So I added this configuration in vite.config.ts:
ts
export default defineConfig({
build: {
cssCodeSplit: true,
rollupOptions: {
output: {
manualChunks: (id) => {
if (id.includes('node_modules')) {
if (id.includes('element-plus')) {
return 'element-plus'
}
if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {
return 'vue-ecosystem'
}
return 'vendor'
}
}
}
}
},
})
Looking at this configuration, I was even a bit smug:
- ✅ Vue family bundled separately → cache-friendly
- ✅ element-plus bundled separately → lazy loading
- ✅ Utility libraries bundled separately → clear structure
Perfect! 😎
Then npm run build, deploy, refresh the page, open the Network panel...
The smile gradually froze.
The data tells me: what is "reverse optimization"
Comparing the two charts, the problem is actually obvious.
Before chunk splitting (fast)
Under Vite's default chunk splitting strategy, the resources looked like this:
index-712a6fc0.js— 340 kB, 319 msindex-37f01f92.css— 328 kB, 327 mslogin-c327e251.js— 9.7 kB, 51 ms- Background image — 69.4 kB, 290 ms
Multiple files loaded in parallel, none waiting for the others, fully utilizing browser concurrent requests, first-screen completed in about 800ms.
After chunk splitting (slow)
After my manual configuration, the situation became:
vendor-a32a4159.js— 1,968 kB, 6.56 seconds
A vendor file nearly 2MB, just downloading took over 6 seconds.
More critically, this huge vendor file blocked the loading of all subsequent resources. The element-plus, CSS, business code that followed all had to wait for this file to download, parse, and execute.
A 2MB monolith file became the single bottleneck for the entire first-screen.
Why did my "optimization" backfire?
After calming down, I analyzed three levels of reasons.
1. The browser is not a pipe; bigger is slower
My thinking at the time was simple: since the total size doesn't change, bundling into one file reduces requests, isn't that faster?
But the browser doesn't work that way.
For a 2MB JS file, the browser needs to:
- Download (6.56 seconds)
- Parse
- Compile
- Execute
During this process, the browser's main thread is completely occupied, the page cannot render, and the user can only stare at a white screen.
When split into multiple smaller files, the browser can:
- Download multiple files in parallel
- Parse and execute while downloading
- Prioritize loading critical resources
Total size is the same, but the loading experience is worlds apart.
2. I destroyed the caching strategy with my own hands
I originally wanted to improve cache hit rate through chunk splitting, but instead I ruined it.
Before chunk splitting, under Vite's default strategy, each third-party dependency had its own chunk:
vue-xxx.jselement-plus-xxx.jsvueuse-xxx.js
If element-plus gets upgraded one day, only the element-plus chunk cache becomes invalid, the rest remain unchanged.
But after my manual configuration:
text
vendor-a32a4159.js ← contains vue + vue-router + pinia + axios
If axios releases a minor version, the entire 1,968 kB vendor cache becomes invalid, and users have to re-download nearly 2MB of files.
Originally wanted to optimize caching, but instead the cache hit rate dropped significantly.
3. Vite's default strategy is much smarter than me
Vite has a built-in splitVendorChunkPlugin, whose strategy is simple but effective:
ts
// Vite internal approximate logic
if (id.includes('node_modules')) {
// Each third-party package becomes a separate chunk
// Leverage browser caching
}
Each third-party dependency is an independent chunk, loaded in parallel, without interfering with each other.
This strategy has been validated by thousands of projects, and is the optimal solution in the vast majority of scenarios.
What should the correct optimization approach be?
This experience made me rethink "frontend optimization."
Step 1: Don't optimize based on intuition
My biggest mistake was: optimizing "by feeling" without a performance problem.
The correct optimization process should be:
text
User feedback/monitoring alerts → Lighthouse/Performance measurement → Locate bottleneck → Targeted optimization → Re-measure and compare
Instead of:
text
I feel this can be optimized → Change configuration → It got slower → Confused
Step 2: Let data speak
Before optimizing, ask yourself a few questions:
- 🟢 What is the first-screen load time? (LCP)
- 🟢 Where do users feel lag?
- 🟢 Is it slow network? Slow rendering? Or slow execution?
- 🟢 Is there monitoring data supporting your optimization direction?
Optimization without data support is likely just wishful thinking.
Step 3: When is custom chunk splitting needed?
Vite's default configuration is already good enough, but there are indeed scenarios that require manual intervention:
| Scenario | Custom needed? | Explanation |
|---|---|---|
| Project just started, first-screen 800ms | ❌ No | Don't touch it, it's already good |
| First-screen load 3s+, low Lighthouse score | ⚠️ Locate first | Find the bottleneck before acting |
| A large library (e.g., ECharts) not needed on first-screen | ✅ Yes | Split it out for lazy loading |
| Multiple entry points share a lot of common code | ✅ Yes | Extract a common chunk |
| Users generally on weak networks | ⚠️ Be cautious | Requires finer control, but still needs data support |
Step 4: If you really need to split chunks, how?
If custom chunk splitting is indeed necessary, remember a few principles:
- Don't stuff everything into one vendor
- Split by package name, not by type
- Don't split resources on the critical path too finely
- Always validate with data after changes
A relatively safe chunk splitting scheme:
ts
manualChunks(id) {
if (id.includes('node_modules')) {
// Split each package individually, not merge all
const pkgName = id.split('node_modules/')[1].split('/')[0]
return `vendor-${pkgName}`
}
}
Or, directly use the official Vite plugin, one line:
ts
import { splitVendorChunkPlugin } from 'vite'
export default defineConfig({
plugins: [splitVendorChunkPlugin()]
})
Final thoughts
Vite team's default configuration is much smarter than your intuitive manual configuration.
This is not to praise Vite, but a simple truth:
If a tool has been validated by thousands of projects, its default configuration is likely optimal in most scenarios. As users, before trying to "optimize" it, we should first understand:
- Why is the default configuration designed this way?
- Does my scenario really need customization?
- Is there data supporting my optimization direction?
This "reverse optimization" taught me one thing:
The essence of optimization is solving problems, not satisfying the psychological need to "tinker." Data first, results speak.
If you are currently looking at Vite's configuration documentation, pondering how to "optimize" the chunk splitting strategy, my advice is:
First run Lighthouse, see what the LCP actually is. If the data tells you "no problem," then really don't touch it.
After all, some optimizations are best left undone.
If this article saved you a few hours (or even an afternoon) of tinkering, give it a like before you go 👋
This article was originally published on Juejin. Feel free to share and discuss. What "reverse optimization" cases have you encountered in your projects? Share in the comments 👇