In modern web development, dependency management, asset bundling, and optimizations can be complex and time-consuming. Bundlers are essential tools that help streamline these processes, enabling developers to focus on other aspects of development.
However, as the project grows, this can take a long time, delaying the resolution of critical bugs with each deployment and leading to a sluggish developer experience. Additionally, it also affects developer efficiency as cold starts and Hot Module Replacement can take considerable time.
This is particularly true for Webpack, one of the most popular bundlers in the industry, with over 30 million downloads per week.
In this article, we will try to understand why Bundlers are essential and explore the regression issues associated with these processes running within, and find ways to reduce the time they take. This will enhance not only the development workflow but also improve developer efficiency.
Why Bundlers are essential
When building a website, the first things we need are HTML, CSS, and JavaScript. This trio seems straightforward enough and has been the standard for a long time. However, creating a high-performance, feature-rich website with excellent developer and user experience is not simple.
For starters, we may use TypeScript, a UI rendering library like React, or a CSS preprocessor like SASS. Then third-party libraries such as Firebase, Lodash, Redux, or Sentry to avoid reinventing the wheel for common functionalities.
After all of this, we still need to ensure compatibility across multiple browsers and legacy versions. This requires polyfills, making the development process feel like attempting to land on the moon with nothing but a chair and balloons.
At my company, we rely on Webpack for our build processes. However, over a certain period and as the project gets bigger this critical tool can also become a bottleneck. On the CI, the entire process takes approximately half an hour—10 minutes for installing dependencies and 20 minutes for bundling.
Understanding Webpack
To understand why Webpack is time-consuming, it's essential to understand the architecture we are working with.
The platform consists of an Express.js backend and a React frontend, each requiring a separate build process. Furthermore, we adhere to the Adaptive design philosophy, meaning we create distinct builds for mobile and desktop, to minimize the bundle size.
The second challenge stems from the nature of Webpack itself. Being written in JavaScript, handling long-running tasks often leads to memory leaks and overall sluggishness.
Here's the prod:build
command, which runs three separate build commands for production:
"build:prod": "run-s build:prod-*",
"build:prod-server": "cross-env webpack --config ./webpack/server.config.ts",
"build:prod-client": "run-s build:prod:client-*",
"build:prod:client-mobile": "cross-env webpack --config ./webpack/client.config.ts",
"build:prod:client-desktop": "cross-env DEVICE=desktop webpack --config ./webpack/client.config.ts",
Now running the build command, Webpack prints this data to the CLI as shown in the image
Component | Time |
---|---|
Server | 111844ms |
Client Desktop | 177699ms |
Client Mobile | 191816ms |
Total | 481359ms |
So it takes, 481 seconds or 8 minutes to build the entire project, in CI, it takes around 20 mins for the whole process.
Webpack Under the hood
Webpack processes your application by building a dependency graph starting from specified entry points. It then combines every module your project needs into one or more bundles, which are static assets used to serve your content.
The core functionality of Webpack includes:
Tree Shaking: Eliminates dead code.
Code Splitting: Splits code into various bundles which can be loaded on demand or in parallel.
Chunk Graph: Organizes modules into chunks for more efficient loading.
Hot Module Replacement (HMR): Allows modules to be updated at runtime without a full refresh during development.
Environment-specific builds: Different builds for development and production environments.
Asset Hashing: Generates unique file names for caching purposes.
In addition to its core functionality, webpack relies on an ecosystem of loaders and plugins to extend its capabilities:
Loaders and Plugins:
Loaders process different types of files and convert them into valid modules that can be added to the dependency graph.
Plugins perform a wider range of tasks like bundle optimization, asset management, and injection of environment variables.
Together, they allow webpack to transform and bundle various asset types (JS, CSS, images, etc.) example babel is used for transforming templates like .jsx, .vue, .svelte to plain javascript, and sass is used for .scss and .sass files to plain css.
Most of the loaders/plugins are not bound to webpack, they can be used with other bundlers and most of them can be used without any bundlers at all.
Webpack's core features, comprising about 20% of its functionality, handle essential tasks like dependency graph construction and code splitting. The remaining 80% comes from its extensible loader and plugin ecosystem. Loaders transform various file types into valid modules, while plugins perform tasks like bundle optimization and asset management.
This architecture allows Webpack to extend its capabilities far beyond its core functionality. By providing a robust plugin and loader system, Webpack becomes a highly extensible platform. This allows developers to create custom plugins, loaders, and configurations tailored to specific project needs. As a result, webpack serves as a foundation for more specialized tools and frameworks like Next.js.
Internal Optimizations
When considering improvements in bundling performance, a common suggestion is to switch to Vite, which offers a significant performance advantage. While it is doable, However because of dependencies on core webpack within our project, and many necessary packages like Loadable which aren’t supported in other bundlers, transitioning to Vite proved challenging. Additionally, strict limitation on the final bundle size made this option less feasible.
Instead, we try to improve the wide array of libraries that Webpack uses as loaders and plugins to extend its capabilities. For starters, we implemented several many changes:
Caching: Add a caching layer to CSS loaders using
cache-loader
. This adds an in-memory caching from the previous compilation.Multi-threading: Use
thread-loader
to enable compilation across a worker pool, reducing time.Parallel Processing: Enable parallel flag in
TerserPlugin
and CssMinimizerPlugin, which will decrease the time taken for minification.
Running the prod:build
command again:
Component | Time |
---|---|
Server | 130202ms |
Client Desktop | 152284ms |
Client Mobile | 139476ms |
Total | 421962ms |
The total build time has been reduced to 421 seconds or 7 minutes, a small improvement of 1 minute.
These changes, along with the removal of image compression steps, such as CompressionWebpackPlugin and ImageMinimizerWebpackPlugin successfully reduced our build time to approximately 12-14 minutes 🎉. Despite this improvement, we can do better. So we need to go deeper to understand performance issues.
Identifying Slowdowns
To enhance Webpack's performance, it's crucial to pinpoint what is causing the slowdown. For this purpose, we can use the RsDoctor Plugin.
This plugin can give data on loader time taken by loaders and plugins, letting us pinpoint bottlenecks in an application.
The benchmarks shown here are taken on a
Mac 1.4 GHz Quad-Core Intel Core i5
processor with8 GB 2133 MHz LPDDR3 RAM
. The numbers won't be 100% accurate, as other multiple processes are running on a device, but they can give us relative information on which loader/plugin is taking the most time.
Now for this test, we'll run only the client build build:prod:client-mobile
So, the benchmark is as follows:
A common pattern you might notice is the use of packages like
sass-loader
andbabel-loader
instead of justsass
orbabel
. This naming convention reflects an important characteristic of these tools: they are designed to be bundler-agnostic. The core packages (sass, babel, etc.) can function independently, whether used with any bundler or even via a CDN. The-loader
suffix indicates a wrapper that allows these tools to integrate seamlessly with webpack or other bundlers. This separation of concerns enables greater flexibility, allowing developers to use these tools in various build setups while maintaining consistency in their core functionality
Analyzing Webpack Results: Focus on JavaScript and CSS
Now, as we can see the biggest pain points are:
For code minimization and mangling:
Terserplugin: (37secs)
CssMinimizerPlugin: (14secs)
Running this command in the src folder to group file types with count:
find . -type f | awk -F. '{print $NF}' | sort | uniq -c | sort -nr
In our project, approximately 95% of our files are either .ts
, .tsx
, or .scss
as shown above. This makes sense as majority of the our build computations are around JavaScript and CSS files.
Transitioning from JavaScript-Based Tooling
Now that we know the slowdowns in the build process and the limitations of JavaScript-based tools, it's time to explore faster alternatives:
Migrating JS/TS Transformation
Babel with 49,577,061 npm downloads per week, is the most used tool for JavaScript transformation, we looked at Esbuild as a replacement but many functionalities, most notably loadable support, are missing. Another alternative SWC, written in Rust, supports all the necessary functionalities we need, and on top of that it has APIs similar to Babel, making migration much smoother than other alternatives:
Configuration for Babel:
{
presets: [
[
"@babel/env",
{
useBuiltIns: isWeb ? "usage" : undefined,
corejs: isWeb ? 3 : false,
},
],
"@babel/typescript",
[
"@babel/react",
{
runtime: "automatic",
},
],
],
plugins: ["@loadable/babel-plugin", "@babel/plugin-transform-runtime"],
env: {
development: {
plugins: isWeb ? ["react-refresh/babel"] : undefined,
},
},
};
SWC
{
sourceMaps: true,
jsc: {
externalHelpers: true,
parser: {
syntax: "typescript",
tsx: true,
dynamicImport: true,
},
transform: { react: { runtime: "automatic" } },
experimental: {
plugins: [["@swc/plugin-loadable-components", {}]],
},
},
env: {
coreJs: "3.26.1",
targets: "Chrome >= 48",
},
};
Upgrading SASS Processing
Node-sass, a JavaScript-based implementation, has been deprecated and is no longer maintained. In contrast, Dart-sass, written in Dart Language from scratch with performance in mind, is also recommended by the same team can speed up SASS transformations.
{
loader: "sass-loader",
options: {
implementation: require("sass"),//uninstall node-sass and add this line
}
}
Minimization
For JavaScript and CSS minimization Terser
and CssMinimizer
, written in javascript, are used, and both of these can be extended to utilize SWC.
Terser
new TerserPlugin({
parallel: true,
minify: TerserPlugin.swcMinify, // needs @swc/core to be installed
}),
CSSMinimizer
new CssMinimizerPlugin({
parallel: true,
minify: CssMinimizerPlugin.swcMinify,// needs @swc/css
}),
Polyfilling
PostCSS is essential to the frontend ecosystem, with 69,473,603 downloads per week, it is bigger than all the above libraries mentioned, and has many features other than polyfilling, it is used by all the frameworks like Next.js, Svelte, Vue, and Tailwind under the hood. LightningCSS, created by the maintainer of another bundler Parcel, and written in Rust, is an excellent alternative. It provides all the functionalities of PostCSS, including autoprefixer, with enhanced performance.
{
loader: 'lightningcss-loader',
options: {
implementation: import("lightningcss")
}
}
Having implemented these new tools for optimization, it's time to measure the impact.
As shown in the updated benchmarks we have reduced the time taken by
Sass from 1min 17s to 18s.
Babel from 2min 47s to 15s.
Terser from 37s to 4s.
CSSMinimizer from 14s to 1s.
Now let's check the prod:build
command again:
Component | Time |
---|---|
Server | 44453ms |
Client Desktop | 67560ms |
Client Mobile | 57691ms |
Total | 169704ms |
The total build time is now 170 seconds or 2 minutes 50 seconds, significantly improving from the initial 8 minutes. This optimization has been achieved by transitioning to faster tooling alternatives like SWC, Dart-sass, and LightningCSS, significantly improving our build time.
On CI, the total time taken has been reduced to 4-6 minutes from the previous 18-20 minutes.
Bonus: Moving from Webpack
Having optimized our build pipeline by tackling the most time-consuming tasks, we can now consider alternative bundlers. Transitioning, however, is complex. It impacts not only the build output but also the integration with over 50 essential libraries. Ensuring compatibility and functionality across all these tools is crucial.
Dependencies include:
- Over 30 compile-time libraries such as SWC, SASS, Jest, etc.
- Loadable, for server-side React file code splitting.
- Monitoring tools like Sentry and New Relic.
- Development utilities such as
webpack-dev-server
,webpack-hot-middleware
, and Storybook.
Additionally, each bundler has its own Chunk Graph Algorithm
and chunk-splitting methods, which critically affect bundle size — a key concern for consumer-facing websites.
While bundlers like Vite and Parcel present challenges due to our specific needs, two newer alternatives for Webpack show promise. Turbopack, the successor to Webpack created by Tobias Koppers, offers substantial performance enhancements. But, its current beta status and exclusivity for Next.js, limits its immediate usability for us.
Exploring Rspack
Rspack prioritizes compatibility while boosting speed. Written in Rust, it integrates seamlessly with existing libraries and supports essential functionalities natively, including Hot Module Replacement (HMR)
, TypeScript
, JSX
, SASS
, css-loader
, and SWC
. Additionally, Rspack maintains a similar architecture to Webpack making it an ideal choice for migration without concerns over library compatibility.
With no changes for any major Loader/Plugins, there’s no requirement to run RsDoctor. Executing the production build command with Rspack:
Total build time has further been reduced to 117s or 1 minute 57 seconds, showing further improvements, the bundle size also remains the same.
Package Install Times
Now that we've discussed optimizations to reduce Webpack build times, let's shift our focus to package installation times. Currently, we are using Yarn 1.22, where the installation process takes approximately 10 minutes.
Here is a benchmark published on Github that compares the performance of various package managers, including Yarn, Pnpm and Bun.
Component | Time |
---|---|
Yarn | 337s |
Pnpm | 110s |
Bun | 40-60s |
The same results are repeated on the CI, yarn takes 10 mins, pnpm takes 2-3 mins and bun takes less than 1 minute.
Conclusion: Significant Improvements in Build Time
After extensive optimization efforts, we have successfully reduced our build time from 30 minutes to approximately 6 minutes. This means that we can now address bugs and implement new features at a much faster rate, significantly improving our development workflow and deployment efficiency.
This also proved that we don't need to revamp our infrastructures or migrate to another bundler completely, improvements can be done on a small level which adds up to a significant amount of time.
These improvements not only streamline our processes but also contribute to a more agile and responsive development cycle at Workplace.
For those who want to follow this, these are the before and after repo for the whole process
Webpack Repo : https://github.com/wellyshen/react-cool-starter
Rspack Repo : https://github.com/jainChetan81/rspack-example
Top comments (0)