跪拜 Guibai
← Back to the summary

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:

  1. Webpack 5 persistent cache + tightened loader scopes
  2. ts-loader transpileOnly + fork-ts-checker
  3. 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

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:

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).