This article is published in English.
Profiling a Slow Webpack Monorepo Build Before Reaching for a New Bundler
How profiling a 20-minute Webpack micro-frontend build pointed to Terser, Babel, installs and Docker caching, and which fixes brought it down to about two minutes.
When frontend builds get slow, the reflex is to blame the bundler and start planning a migration. That reflex is often wrong. This case study follows a micro-frontend platform whose Webpack builds had grown past 20 minutes, where profiling showed that the bundler itself was not the problem and a series of targeted changes brought builds down to roughly 2 minutes without replacing Webpack. You will see how to find the real bottleneck, which Rust-based tools replaced which stages, and why dependency installation, registry tokens and Docker layering mattered as much as any loader.
When build time becomes a platform problem
The platform in question received over 600 pull requests a month from more than ten engineers, which translated into more than 5,000 CI runs. At 20 minutes per build, that is over 1,600 hours of CI time monthly. At this volume, slow builds stop being an annoyance and become a bottleneck for the whole organization: feedback arrives late, CI queues back up, and urgent production fixes wait in line.
The shape of the monorepo made things worse:
20 production apps: React and Next.js applications
7 shared libraries
1 centralized E2E test suite
~3,950 TSX/JSX files and 6,600+ TypeScript source files
~350 reusable component modules
144 npm dependencies (74 production, 70 development)
Workspace: single hoisted monorepo
Team: 10+ engineers, 600+ PRs/month
CI: 5,000+ build runs/month
Twenty applications sharing seven libraries in one hoisted workspace means any tooling change touches everything at once.
Why profiling came before migration
Swapping bundlers across 20 production apps, 7 shared libraries and 144 dependencies is a high-risk project. A regression in one shared library can ripple into every app, and re-verifying all build outputs could take weeks with no guarantee of a win. The team therefore profiled the full pipeline first. That exercise surfaced significant costs outside the bundler entirely, in type-checking, test orchestration and dependency resolution, none of which a new bundler would have improved.
The broader lesson is familiar from any performance work: optimizing before measuring turns every change into a guess. A Webpack build moves through several distinct phases before it produces an artifact:
- module compilation
- loader execution
- chunk generation
- asset optimization
- minification
- asset emission
Without timings for each phase you cannot tell which one deserves attention, so nothing in the loader or plugin configuration was touched until the numbers were in.
Finding the hotspot with ProgressPlugin
Webpack ships with ProgressPlugin, which needs no extra install. You require Webpack in the config file:
const webpack = require('webpack');
and add the plugin to the plugins array:
plugins: [
new webpack.ProgressPlugin()
]
With profiling output enabled, one line stood out immediately:
[webpack] 92% sealing > asset processing
TerserPlugin took 825.31s
A single plugin was consuming more than 13 minutes. Compared with its neighbors in the optimization stage, the imbalance is stark:
copy-webpack-plugin 30ms
WriteIndexHtmlPlugin 22ms
RealContentHashPlugin 58ms
CompressionPlugin 70ms
LicenseWebpackPlugin 1.15s
TerserPlugin 825.31s
Every other plugin finished in milliseconds or, in the case of the license plugin, about a second. The bundler was not slow; JavaScript minification with Terser was. That single observation redirected the entire effort.
Loader-level timings with Speed Measure Plugin
ProgressPlugin shows which stage is slow, but not which loader chain inside compilation is responsible. For that the team added speed-measure-webpack-plugin, which wraps the exported configuration:
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');
const smp = new SpeedMeasurePlugin();
module.exports = smp.wrap(config);
SMP reports how long each loader chain spends processing modules, which made it possible to compare the pipeline before and after introducing SWC. It also delivered an honest reality check. The CSS chain of mini-css-extract-plugin, css-loader and postcss-loader did not get faster; it rose slightly from 8.66 to 9.36 seconds. CSS and modules not handled by any loader together accounted for almost 16 of the remaining 20.78 seconds of general output time. Instead of arguing about tool preferences, the team had concrete numbers showing where the next cut should go.
One caveat worth knowing: SMP wraps plugins and loaders to time them, and it is known to conflict with some newer plugins, so use it as a diagnostic run rather than leaving it in the production config.
Goals that constrained every decision
Before changing anything, the team wrote down objectives in three groups.
Build performance
- Lower cold build latency.
- Faster incremental builds.
- Less time in transpilation and minification.
- Better use of available CPU cores.
CI efficiency
- More reuse of Docker cache layers.
- Shorter dependency installation.
- More deterministic pipelines.
Platform stability
- Keep tooling stable at enterprise scale.
- Avoid risky migrations.
- Stay compatible with the existing ecosystem.
- Weigh raw speed against long-term maintainability.
Why Vite and Rspack were set aside
Vite was evaluated seriously. It is excellent software and often the right choice for new or simpler projects. The key difference was that it was benchmarked against this actual pipeline rather than the small demos found in many migration guides. The conclusion was that the dominant cost, minification, is independent of the bundler: moving to Vite would have taken about four weeks and left the team facing much the same bottleneck, with a new configuration format and plugin incompatibilities to sort out. The decision log recorded Vite as something to revisit if compilation ever became the main bottleneck.
A fair nuance: Vite minifies JavaScript with esbuild by default, not Terser, so a migration would in practice have changed the minifier too. That reinforces the real point rather than undermining it. The lever was the choice of minifier, and Webpack can use the same fast minifier without a migration.
Rspack, a Rust-based bundler with a Webpack-compatible configuration, was also considered and looked promising. At the time, recent security incidents and a still-maturing ecosystem made the team cautious, so it stayed on the watch list for later reevaluation. Check its current state before drawing your own conclusion.
Rebuilding the slowest stages
With the strategy settled, the work shifted to replacing the slowest components one at a time.
SWC instead of Babel for transpilation
Transpiling JavaScript and TypeScript was one of the largest remaining costs. Babel was replaced by SWC, a Rust-based compiler designed for high-throughput JS and TS transforms, wired in through swc-loader and preceded by thread-loader:
{
test: /\.(jsx?|tsx?)$/,
use: [
'thread-loader',
{
loader: 'swc-loader'
}
]
}
Public benchmarks show SWC transpiling far faster than Babel, and in this pipeline the switch brought quicker transpilation, parallel work through thread-loader, less blocking of the main process and shorter CI runs. The swc-loader entry here relies on an .swcrc file or inline options for parser and JSX settings; without them, TypeScript and JSX syntax will not parse.
esbuild instead of Terser for minification
The profile had already named the main culprit, so JavaScript minification moved to esbuild through esbuild-loader's plugin:
new EsbuildPlugin({
target: 'es2015',
minify: true
})
The choice was informed by the minification-benchmarks project, which compares esbuild, terser, swc, uglify-js and others on minified size, gzipped size and time. In that data:
- esbuild's minified and gzipped output was competitive, a little larger than the best result but within roughly 5 to 8 percent of it;
- esbuild took around 295 ms where terser took about 6.7 seconds on the same input, a gap that multiplies across a monorepo;
@swc/coreandoxc-minifyproduced smaller output, but their speed trade-offs and integration story tipped the decision toward esbuild.
The target: 'es2015' setting tells esbuild which syntax it may emit; make sure it matches your real browser support, since a newer target lets it produce shorter code.
LightningCSS for CSS minification
CSS minification switched to LightningCSS from the Parcel team, plugged into css-minimizer-webpack-plugin as its minify function:
new CssMinimizerPlugin({
minify: CssMinimizerPlugin.lightningCssMinify,
})
The LightningCSS benchmarks compare it with cssnano and esbuild's CSS minifier. They show it as the fastest in every scenario tested, with consistently smaller output and especially large gains on big stylesheets such as Tailwind builds. For a CI pipeline where CSS optimization adds directly to total latency, better compression combined with less time made it the clear pick. Across both minifier changes, JavaScript minification became about 10 times faster and CSS optimization about 6 times faster.
Using every CPU core
CI agents usually have several cores, yet many pipelines leave most of them idle. Adding thread-loader in front of expensive loaders spreads that work across workers:
use: ['thread-loader', 'swc-loader']
That reduced cold build bottlenecks, improved resource usage and raised CI throughput. Keep in mind that each worker has startup and message-passing overhead; for an already fast loader like SWC on a small project, workers can cost more than they save, so measure with and without it.
Faster, more deterministic installs with pnpm
Compilation was not the only cost; installing dependencies also ate CI minutes. The team compared npm, yarn, pnpm and bun using public benchmarks covering install speed, disk usage and resolution models. Bun looked strong in synthetic tests, but pnpm won on ecosystem maturity, Node.js compatibility and a track record in large production systems.
Where npm copies packages into each node_modules, pnpm keeps a content-addressable global store: each package version is downloaded once and hard-linked into projects. That brings several benefits:
- installs are faster because packages are fetched once and reused;
- disk usage drops because workspaces no longer duplicate the same packages;
- strict resolution blocks phantom dependencies, packages your code imports without declaring them, which makes builds more deterministic;
- Docker layer caching improves because the store can be reused between builds.
In Docker, a BuildKit cache mount keeps the pnpm store across builds, while --frozen-lockfile fails the install if the lockfile is out of date:
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
An authentication detail that broke caching
One of the most effective fixes had nothing to do with frontend tooling. Packages came from AWS CodeArtifact, whose authorization tokens expire after 12 hours. Fetching tokens repeatedly inside the pipeline caused cache invalidation, repeated authentication work and busted Docker layers, because a changing value fed into a layer changes that layer's cache key.
The fix was to request the token once per Jenkins job and reuse it for every step:
export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
--domain <domain> \
--domain-owner <owner> \
--query authorizationToken \
--output text)
That led to better layer reuse, fewer redundant installs and steadier pipelines. If you pass such a token into a Docker build, prefer a secret mount over a build argument so it neither ends up in the image history nor invalidates the cache.
Layering Dockerfiles for cache hits
Finally, the Dockerfiles were reorganized into layers ordered from least to most frequently changing:
- the base runtime;
- dependency installation;
- copying the source;
- the Webpack build.
Combined with --mount=type=cache for the package store and --mount=type=secret for credentials, this ordering meant a typical code change rebuilt only the last two layers, substantially increasing cache reuse.
Key takeaways
- Treat build speed as a systems problem. Here the biggest wins came from the minifier, installs, credentials and Docker layering, not from the bundler.
- Profile first.
ProgressPluginfound a 13-minute Terser step in minutes; a speculative migration would have taken weeks. - Rust-based tools such as SWC, esbuild and LightningCSS compound: each one removes a separate slice of latency.
- pnpm and disciplined Docker caching improve determinism as well as speed.
- Anything that changes on every run, even an auth token, can silently destroy caching.
- Keep migrations on the table but gated by data. By modernizing individual stages, this platform went from over 20 minutes to about 2 while keeping its ecosystem intact.