How I Slashed a Legacy Build from 4.5 Minutes to 8 Seconds — and Got Away with It
I Cut Our Legacy Project's Build Time by 90%, and My Boss Thought I Just "Optimized a Bit" — Then the Neighboring Team's CI Broke and Came Asking for My Config
This article is not a tutorial, but a complete postmortem of an "underground refactor."
Here's What Happened
The company had a three-year-old React project, Webpack 4 + Babel 7 + a bunch of custom loaders, with a build time stuck at around 4 minutes 30 seconds.
Every time I changed a line of copy, I had to wait for hot reload and drink a glass of water. Submitting a PR meant I could go to the bathroom and come back before the CI finished.
Last week, I couldn't take it anymore. After the Friday afternoon requirements review, I created just one Jira ticket: "Optimize build config," estimated at 2 story points.
My boss glanced at it: "Okay, a small optimization. Go ahead."
He had no idea what I did over the weekend.
Saturday: First, Figure Out What Those 4.5 Minutes Were Doing
# Start with proper profiling
DEBUG=webpack* npm run build 2> webpack.log
Then I wrote a script to analyze it:
// analyze-build.js
const fs = require('fs');
const log = fs.readFileSync('webpack.log', 'utf8');
// Extract each loader's time
const loaderTimes = log.match(/(\d+)ms.*?loader/g) || [];
console.table(loaderTimes.map(s => {
const [time, name] = s.match(/(\d+)ms.*?(\w+)-loader/).slice(1);
return { loader: name, time: +time };
}).sort((a, b) => b.time - a.time));
The results left me speechless:
| Loader | Time | Notes |
|---|---|---|
babel-loader |
127s | Full transpilation, including node_modules |
ts-loader |
89s | Type checking bundled with compilation |
sass-loader |
45s | No fiber enabled, synchronous parsing |
eslint-loader |
38s | Full lint during build, including third-party libs |
babel-loader was transpiling lodash inside node_modules.
I stared at this result for three minutes.
Saturday Night: First Cut, Take the Most Obvious
1. Tighten babel-loader's include
// Before: no include, full transpilation
{
test: /.(js|jsx)$/,
use: ['babel-loader'] // 127s
}
// After: only transpile src + necessary esm packages
{
test: /.(js|jsx)$/,
include: [
path.resolve(__dirname, 'src'),
// Only transpile packages that are published as esm and need compatibility
path.resolve(__dirname, 'node_modules/@company'),
path.resolve(__dirname, 'node_modules/xxx-esm-pkg')
],
use: ['babel-loader']
}
This cut: 127s → 34s.
But it wasn't enough. The 89s from ts-loader was still ridiculous.
2. Separate Type Checking from the Build Pipeline
// Before: ts-loader does its own type checking, blocking compilation
{
test: /.tsx?$/,
use: [
'babel-loader', // transpilation
{
loader: 'ts-loader',
options: { transpileOnly: false } // default is false!
}
]
}
// After: ts-loader only transpiles, type checking goes to fork-ts-checker
{
test: /.tsx?$/,
use: ['babel-loader', 'ts-loader'] // ts-loader default transpileOnly: false?
// Actually, ts-loader's default is indeed false, but we can explicitly optimize
}
Actually, I changed my approach:
// webpack.config.js
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
module.exports = {
module: {
rules: [{
test: /.tsx?$/,
use: [
'babel-loader',
{ loader: 'ts-loader', options: { transpileOnly: true } }
]
}]
},
plugins: [
new ForkTsCheckerWebpackPlugin({
typescript: { diagnosticOptions: { semantic: true, syntactic: true } }
})
]
};
Type checking moved to a child process, no longer blocking the main build pipeline.
This cut: 89s → 21s (ts-loader part), and hot reload no longer waits for type checking.
3. sass-loader: Enable fiber + Persistent Cache
{
test: /.scss$/,
use: [
'style-loader',
'css-loader',
{
loader: 'sass-loader',
options: {
implementation: require('sass'),
sassOptions: { fiber: false } // sass 1.33+ deprecated fiber, but...
// Actually, I upgraded sass-loader and enabled webpack5's persistent cache
}
}
]
}
Here I went straight to Webpack 5's persistent cache:
// webpack.config.js
module.exports = {
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename]
}
}
};
Second build: 4 minutes 30 seconds → 38 seconds.
Cold start still had room for improvement, but hot cache was already flying.
Sunday: I Had a Wicked Idea
The build was faster, but I was looking at that three-year-old webpack.config.js mess, and my fingers itched.
This project was ejected from CRA back in the day. The config still had eslint-loader (from the CRA 3 era), a mix of url-loader and file-loader, optimize-css-assets-webpack-plugin with cssnano...
I opened the docs and saw: Vite 5 is stable now.
But migrating to Vite was too risky; I didn't dare. And the Jira ticket said "Optimize build config," not "Refactor build tool."
So I compromised: Use Rspack for a "painless migration."
Rspack is from ByteDance, Webpack API compatible, but rewritten in Rust, claiming 5-10x build speed.
I thought: It's still Webpack config; trying it won't hurt.
npm install @rspack/core @rspack/cli --save-dev
Then I renamed webpack.config.js to rspack.config.js, and the API barely needed changes:
// rspack.config.js
const rspack = require('@rspack/core');
module.exports = {
// 90% of the config can be copied directly
module: {
rules: [
// babel-loader replaced with @rspack/plugin-react
{
test: /.(js|jsx|ts|tsx)$/,
use: {
loader: 'builtin:swc-loader', // Rspack has built-in SWC, much faster than babel
options: {
jsc: {
parser: { syntax: 'typescript', tsx: true },
transform: { react: { runtime: 'automatic' } }
}
}
}
}
]
},
plugins: [
new rspack.HtmlRspackPlugin({ template: './public/index.html' }),
// Most other plugins can be used directly
]
};
I ran it:
npx rspack build
Cold build: 28 seconds.
With cache: 8 seconds.
I checked three times. No errors, output was normal, Source Maps were there.
Monday: I Told a Little Lie
At the morning standup, my boss asked: "How's the build optimization going?"
I said: "Yeah, I tweaked some loader configs. The build is a bit faster."
Boss: "Good, keep pushing on the requirements."
I nodded, committed rspack.config.js, and titled the PR:
chore: optimize build config and enable persistent cache
The code did have the Webpack 5 cache config (I kept dual configs — Rspack as the main one, Webpack 5 as a fallback), so it wasn't a complete lie.
Wednesday: Things Started to Spiral
The day after the PR was merged, I got a Feishu message:
Backend from the neighboring team @me: "How is your team's CI so fast? Our frontend project build takes 6 minutes. Can I reference your config?"
Before I could reply, another one came:
Frontend lead from Business Line B: "I heard you optimized the build? Can you send me your
rspack.config.js?"
Then someone in the architecture group @me:
"Hey, do you have documentation for that Rspack migration? Our team wants to try it too."
I panicked.
Because I hadn't written any migration documentation. That rspack.config.js was something I hacked together over the weekend, with comments I'd already forgotten the meaning of:
// TODO: This plugin might not be needed? Keep it for now
// FIXME: This might have issues in production, test over the weekend
And the most awkward part: Our CI was still using the webpack command. Rspack was only for my local development.
That meant CI was running the optimized Webpack 5 (around 28 seconds), while local development used Rspack (8 seconds).
But the neighboring team thought I had switched the entire pipeline.
Thursday: Forced to Write "Documentation"
In the afternoon, I was pulled into an ad-hoc meeting titled "Frontend Build Optimization Experience Sharing."
Attendees: 3 frontend leads from different business lines + the architecture group + my boss.
My boss looked at me: "You did a great optimization. Want to share with everyone?"
Me: "..."
Then I spilled everything I did over the weekend:
- Webpack 5 persistent cache + tightened loader scopes
- ts-loader
transpileOnly+fork-ts-checker - Local development switched to Rspack (CI still on Webpack 5)
Someone from the architecture group asked: "Why not switch CI to Rspack too?"
I said: "Rspack's replacement for copy-webpack-plugin has a bug. I haven't finished testing it in production yet..."
The truth was, I ran out of time over the weekend.
My boss listened, was silent for a moment, then said: "Well, switch CI this week too. Write a migration document for the other teams to reference."
Me: "...Okay."
Current Situation
- Our team: Local Rspack (8s), CI Rspack (15s), stable for two weeks
- Business Line A: Copied my config, but ran into compatibility issues between SWC and babel-plugin-import. I fixed it.
- Business Line B: Went straight to Vite, saying "Since we have to migrate anyway, might as well go all the way."
- Business Line C: Still on Webpack 5, but using my optimization plan, build time dropped from 5 minutes to 40 seconds.
My Jira ticket is still 2 story points, status "Done."
But my Feishu signature changed to: "For build optimization consulting, please send a red envelope first."
Some Serious Summaries
| Optimization Method | Gain | Risk |
|---|---|---|
| Webpack 5 persistent cache | 10x on second build | Cache invalidation strategy must be configured properly |
Tighten loader include |
Reduce 60%+ useless compilation | Need to confirm which packages require transpilation |
ts-loader + fork-ts-checker |
Compilation and type checking in parallel | Type errors don't block build; need CI check |
| Rspack/SWC | 5-10x on cold build | Some babel plugins are incompatible; need to verify one by one |
Finally
Refactoring doesn't always require a "project approval," "technical design review," or "sprint planning."
All you need is:
- A pain point you can't stand anymore
- A weekend without overtime
- A Jira ticket that just says "optimize a bit"
Then, wait for your colleagues to come asking for your code.
Have you ever had a similar "underground refactoring" experience? Share in the comments.
If this article was helpful, feel free to like and bookmark. If you run into build optimization issues, leave a comment. I'll reply when I see it (but maybe not right away, because I might be fixing the neighboring team's CI).