Webpack, Vite and Rspack/Rsbuild: Inside Three Generations of Build Tools
Bài này có bản tiếng Việt. Đọc bản tiếng Việt →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
Three months ago I sat down and re-measured the build times of a monorepo: dev server cold start of 41 seconds, average HMR of 1.4 seconds, CI build of 9 minutes. After swapping the bundler, the first two numbers dropped to 4 seconds and 180 milliseconds. CI only dropped by about a third.
That’s a visible mismatch. And the reason isn’t “Rust is faster than JavaScript”. These three build tools do their work at different moments, and of the numbers above, only one actually measures the moment you care about.
webpack, Vite and Rspack/Rsbuild differ in three ways: when the work gets executed, what language the engine runs on, and how tightly they’re bound to the old ecosystem. The heavy part lives in the internals: the build lifecycle, the transform pipeline, the boundaries between layers. Real performance lives there too.
1. Three generations, three models of time
The most useful way to classify them isn’t by release year, but by one question: when does the browser get its first byte, and what does it get?
- webpack (2012): the browser only ever sees a finished bundle. The whole module graph is built and optimized before any request is served.
- Vite (2020): the dev server bundles nothing. The browser fetches each module itself through native ESM, and the server transforms on demand. Production still bundles.
- Rspack (2024): keeps webpack’s eager-bundle model, but swaps the JavaScript engine for Rust and the transforms for SWC.
timeline
title Three generations of build tools
2012 : webpack 1 — popularized the "everything is a module" model
2020 : webpack 5 — persistent cache and Module Federation
: Vite 1.0 — native ESM in dev, Rollup in prod
2024 : Rspack 1.0 and Rsbuild 1.0 — Rust engine, webpack-compatible API
2026 : Vite 8 — Rolldown replaces both esbuild and Rollup
: Rspack 2.x — ESM core, twice as fast as 1.0
All three produce good bundles. The difference is when you pay. webpack pays upfront, once, in developer waiting time. Vite pays later, in small pieces, and in dev you barely pay at all. Rspack also pays upfront, but the bill is many times smaller.
2. Inside webpack: a fixed lifecycle, two heavy phases
Webpack treats everything (JS, CSS, images, fonts, WASM) as a node in the same module graph. But “it has a graph” isn’t the part worth understanding. Its build lifecycle is a fixed sequence of phases, and every plugin hangs off that sequence.
flowchart TB
subgraph SETUP["Setup — runs once"]
A1["compiler created"] --> A2["environment → afterEnvironment<br/>entryOption → afterPlugins → afterResolvers → initialize"]
end
subgraph BUILD["Every build creates ONE new Compilation"]
B1["beforeRun → run"] --> B2["beforeCompile → compile<br/>thisCompilation → compilation"]
B2 --> B3["MAKE<br/>addEntry → resolve → buildModule → succeedModule<br/>…repeat for every dependency…<br/>finishMake → finishModules"]
B3 --> B4["SEAL<br/>seal → optimizeDependencies → beforeChunks → afterChunks<br/>→ optimize → optimizeModules → optimizeChunks → optimizeTree<br/>→ moduleIds → chunkIds → codeGeneration → runtimeRequirements<br/>→ beforeHash → afterHash → processAssets → afterSeal"]
B4 --> B5["emit: afterCompile → shouldEmit → emit → assetEmitted → afterEmit"]
B5 --> B6["done → afterDone"]
end
A2 --> B1
B6 -.->|"watch mode: compiler is reused,<br/>new Compilation, rerun from compile"| B2
Two phases carry nearly all the work, and they’re different in nature.
The make phase builds the module graph. Each entry is handed to a module factory, which resolves the request to a file, and then the module is built: the loader chain runs over the source, and the result is parsed to find dependencies. Each dependency found is resolved and built the same way, so the graph keeps growing until nothing new turns up. finishModules is the first moment the whole graph exists.
The seal phase turns the graph into output. Modules get assigned to chunks, the chunk graph is optimized, ids and hashes are assigned, code is generated, and only after that do assets exist. This ordering explains a classic mistake: if you want to modify an asset, you do it in processAssets, not in the compiler’s emit hook. By the time emit runs, the compilation is already sealed.
Architecturally, Compilation is the unit of one build. In watch mode the compiler is reused, but everything from compile downward reruns on a fresh Compilation. So state a plugin keeps in compiler instance variables survives rebuilds, while state kept on the Compilation gets reset. A hard bug to track down.
As for modules and chunks, there are two graph layers, not one: ModuleGraph (which module imports which) and ChunkGraph (which chunk contains which modules, which chunk loads which). An entry creates a chunk group containing one chunk; SplitChunksPlugin splits that chunk into several. Chunks come in two kinds, initial (loaded with the entry) and non-initial (lazy, produced by dynamic imports or splitChunks), and each chunk maps one-to-one to an output file.
The extension points split along those same two layers:
- A loader turns one file into a module, and runs as a chain from right to left:
["style-loader", "css-loader", "sass-loader"]means Sass runs first, then CSS, then style. - A plugin doesn’t touch files; it taps into hooks on
CompilerandCompilation. The two groups differ in scope: compiler hooks mark the phases, while compilation hooks cover everything insidemakeandseal.
Why is this model slow? Not because JavaScript is “weak”. Every step has to finish before the next begins, on a single main thread. Resolving thousands of imports, parsing ASTs, generating source maps, building the chunk graph: all sequential. webpack 5 has cache: { type: "filesystem" } and parallelizes a few side steps, but the body of the pipeline is still single-threaded.
In exchange, you get control down to every byte of output, Module Federation for sharing modules between apps at runtime, and an ecosystem more than ten years deep. webpack isn’t standing still either: the 2026 roadmap heads toward webpack 6 with TypeScript built into core (dropping ts-loader), native HTML entries (dropping html-webpack-plugin), experimental native CSS, and a “lazy barrel” optimization, which they openly say was inspired by Rspack.
3. Inside Vite: on-demand transforms, and the Environment API
Vite gets ahead in exactly one place: in dev, it doesn’t need to bundle. Modern browsers understand ESM, so the dev server only has to return the file the browser asks for, and let the browser follow the import graph on its own.
But “no bundling” doesn’t mean “no pipeline”. Vite still has a module graph. It just lives on the server, and it’s built up gradually, request by request, instead of once before serving.
flowchart LR B["Browser"] -->|"GET /src/main.tsx"| E["environment.transformRequest(url)"] E --> P1["resolveId<br/>plugin container"] P1 --> P2["load<br/>read file or virtual module"] P2 --> P3["transform<br/>plugin pipeline"] P3 --> MG["environment.moduleGraph<br/>records the imports just found"] MG --> RES["return rewritten ESM<br/>imports point to real URLs"] RES --> B B -->|"native import — bypasses the server"| B DEP["Dependency in node_modules"] -.->|"pre-bundled once"| PB["deps cache"] PB -.-> RES WS["Watcher"] -->|"file changed"| INV["invalidate module in graph"] INV -->|"WebSocket"| B
The core mechanism is environment.transformRequest(url): it uses the plugin pipeline to resolve the url to a module id, load the content (from a file, or from a plugin acting as a virtual module), then transform the code. During the transform, imports and metadata are recorded in the environment’s module graph by creating or updating the matching module node. That’s the whole secret of Vite dev. No magic beyond doing exactly one module, when it’s asked for.
Two details get misunderstood a lot. First, Vite does still use a bundler in dev, but only for node_modules, which is called pre-bundling. Many packages are still CJS/UMD, and a package like lodash-es with hundreds of files would produce hundreds of requests if it weren’t merged. Second, cold start doesn’t depend on how many modules your app has, but on how many dependencies. Adding 200 source files doesn’t slow startup. Adding one heavy dependency does.
The most interesting part architecturally is the Environment API (since Vite 6). Before it, a dev server always had exactly two environments (client and ssr) but only one blended module graph: nodes had separate clientImportedModules and ssrImportedModules, and a transform result had to carry a boolean ssr flag. The consequence: every API had to take an extra ssr parameter, and given only an id you couldn’t tell which environment a node belonged to.
Since Vite 6, everything is split per environment: environment.moduleGraph, environment.pluginContainer, environment.hot, environment.transformRequest(). Inside a plugin hook, this.environment tells you the current environment along with its config and graph. And the number of environments is no longer capped at two: you can define client, ssr, edge, worker, or any name you like. For builds, the client environment always exists, and ssr only exists if you configure it explicitly.
Everything above is about dev. Production from Vite 1 through Vite 7 is a different story entirely: esbuild in dev, Rollup in prod. Two engines means two configuration models (esbuild options for dev, rollupOptions for prod). Worse in terms of mental load: dev and prod can produce different results in rare cases. The price of optimizing for dev is that you temporarily give up the fidelity of the “prod build”.
Vite 8 (stable in 3/2026) removes that price. Rolldown, VoidZero’s Rust bundler, replaces both: it does the dev transform, dependency pre-bundling and the production build. Oxc handles JS/TS parsing and transforms, and Lightning CSS handles CSS. The architectural consequence matters more than speed: previously parsing and transforming happened in esbuild, and then the graph had to be serialized back and forth for Rollup to build the bundle. Now Rolldown and Oxc share the same data representation in Rust, so one serialization boundary goes away. rollupOptions still exists because Rolldown was designed to be Rollup API-compatible.
Swapping engines isn’t free: once esbuild and Rollup leave the pipeline, plugins that depend on their specific behavior have to follow. That’s why the recommended migration path has two steps: first swap vite for the rolldown-vite package on Vite 7, to separate bundler-caused bugs from Vite 8-caused ones.
4. Inside Rspack: the Rust-JavaScript boundary is the real problem
Rspack approaches from the opposite direction to Vite: keep webpack’s model, replace the execution. But “rewrite it in Rust” sounds a lot simpler than actually doing it. The loaders and plugins of an entire ecosystem are JavaScript, and every call back and forth between the two languages has a cost.
Rspack’s crate structure makes that clear:
flowchart TB
subgraph JS["JavaScript side (Node.js)"]
PKG["@rspack/core<br/>webpack-compatible API"]
LDR["Community loaders and plugins"]
end
subgraph BRIDGE["Bridge"]
NAPI["rspack_napi — Node-API"]
BIND["rspack_binding_api + node_binding<br/>generate the native module"]
end
subgraph RUST["Rust side"]
CORE["rspack_core<br/>module system, dependency graph,<br/>compilation pipeline"]
MAIN["rspack<br/>integrates core + plugins"]
SWCP["swc_plugin_import<br/>swc_plugin_ts_collector"]
ALLOC["rspack_allocator<br/>mimalloc"]
end
PKG --> BIND --> NAPI --> CORE
LDR --> BIND
MAIN --> CORE
CORE --> SWCP
ALLOC --- CORE
rspack_core is the real engine: module system, dependency graph, compilation pipeline. rspack_binding_api and node_binding generate the native module that JavaScript calls into. @rspack/core is the JS layer that provides the webpack-compatible API. There’s also rspack_allocator, which uses mimalloc on Linux/macOS. A small detail, but it says this system is optimized at the memory allocation level, not just the algorithm level.
Because loaders and plugins are JS, Rspack has to treat the Rust-JS boundary as an expensive resource. A few optimizations they published for Rspack 1.0:
- If a JS-side hook isn’t used, the Rust side doesn’t call into JS at all. No cost for what you don’t use.
- Many message objects are lazy-loaded: even when an object is large, if JS only reads a few properties, Rspack passes across only the part that gets used.
- The long-term plan is to open up a stable Rust API for higher-level tools, so heavy integrations don’t have to go through the JS bridge, which is the real bottleneck of this model.
On that foundation, Rspack keeps the most important contract: module.rules, resolve, optimization.splitChunks, the loader API, the plugin API with the same hook system. Migration is therefore usually not about rewriting config but about writing an adapter: copy the original config and override exactly the values and plugins that aren’t compatible. Yelp described exactly this approach when migrating their system: keep the webpack config file intact and let the adapter spell out each difference, rather than mixing the two models together.
Rspack 2.x adds an ESM core, more modern output, fewer dependencies, and a few lazy-style output optimizations: optimization.splitChunks.dedupDepth (default 1 in production, 0 in development) lets you trade build time for finding duplicated code across routes, with some cases cutting total output size by almost 10%. 2.1 adds a Rust version of React Compiler, import.meta.glob, TypeScript 7 support and automatic persistent cache cleanup.
xychart-beta
title "Rspack 10k React — production build (seconds, lower is better)"
x-axis ["1.7 no-cache", "1.7 cached", "2.0 no-cache", "2.0 cached", "2.1 no-cache", "2.1 cached"]
y-axis "seconds" 0 --> 3.5
bar [3.12, 2.09, 2.66, 1.36, 2.22, 1.20]
xychart-beta
title "HMR (ms) on the same benchmark"
x-axis ["1.7.11", "2.0.0", "2.1.0"]
y-axis "ms" 0 --> 150
bar [129, 113, 107]
Looking at the 1.7 → 2.0 → 2.1 curve, most of the gain comes from caching and arrives incrementally, not from the raw speed of Rust.
5. Inside Rsbuild: one plugin system, two hook layers
It’s worth separating two things that often get lumped together:
- Rspack is a bundler. Its role is equivalent to webpack, with no defaults for React/TS/HTML.
- Rsbuild is a build tool on top of Rspack, with a role equivalent to Vite: it ships a dev server, HTML entries, CSS/asset/TS handling, and a much leaner config.
Rsbuild’s architecture bets on one idea: most functionality is a plugin, with a thin core. A plugin is a function that takes options and returns an object with name + setup(api), deliberately closer to esbuild than to webpack, so plugins are shorter to write.
The best design point is that Rsbuild doesn’t take over Rspack’s hooks. You have two independent hook layers, and you can use whichever one you want:
flowchart TB
subgraph L1["Rsbuild layer — build tool lifecycle"]
direction LR
H1["modifyRsbuildConfig"] --> H2["modifyEnvironmentConfig"] --> H3["modifyBundlerChain"] --> H4["modifyRspackConfig"]
H4 --> H5["onBeforeCreateCompiler → onAfterCreateCompiler"]
H5 --> H6["onBeforeEnvironmentCompile → onAfterEnvironmentCompile"]
H6 --> H7["modifyHTMLTags → modifyHTML"]
H7 --> H8["onAfterBuild / onAfterDevCompile"]
end
subgraph L2["Rspack/webpack layer — per module, per dependency"]
direction LR
R1["normalModuleFactory<br/>resolve, buildModule"] --> R2["succeedModule → finishModules"] --> R3["optimizeChunks → processAssets"]
end
L1 --> L2
G["Global hooks: run once<br/>Environment hooks: run per environment"] -.-> L1
The Rsbuild layer splits into global hooks and environment hooks. modifyRsbuildConfig, onBeforeStartDevServer, onBeforeBuild, onAfterBuild are global: they run once, and all environments share the dev server, middleware and WebSocket. modifyEnvironmentConfig, modifyBundlerChain, modifyRspackConfig, modifyHTMLTags, onBeforeEnvironmentCompile are environment hooks: they run per environment, and can run multiple times if the same plugin is registered in several environments. This is a real trap when writing multi-environment plugins: side effects in setup multiply with the number of environments.
Hook ordering is also defined explicitly, and when several plugins register the same hook, callbacks run in registration order, unless you declare order: "pre" | "post". webpack has no equivalent: there you have to use stage on each hook or tapable tap options.
And when the upper layer isn’t enough, you reach down to the lower one: api.modifyRspackConfig to push a Rspack/webpack plugin straight into the config. That’s how Rsbuild keeps its API simple without closing the door on the old ecosystem.
In exchange, there’s one detail worth thinking about: Rsbuild 2.0 dropped webpack support entirely. In v1, provider: 'webpack' existed mostly to prove that Rspack is compatible with webpack: a verification tool, not a product feature. Once compatibility was solid enough, they removed it along with tools.webpack and tools.webpackChain.
For the same reason, choosing Rspack directly versus Rsbuild depends on the config you already have, not on speed. If your config is already complete and deeply tuned, Rsbuild’s defaults layer can duplicate plugins and cause unexpected changes, and that’s why Yelp went straight to Rspack.
6. Head-to-head comparison
If I could draw only one chart, I’d draw the trade-off between speed and ecosystem richness:
quadrantChart
title Speed at scale vs ecosystem compatibility
x-axis "Slow" --> "Fast"
y-axis "Narrow ecosystem" --> "Broad ecosystem"
quadrant-1 "Fast and rich ecosystem"
quadrant-2 "Slow but rich ecosystem"
quadrant-3 "Slow and narrow"
quadrant-4 "Fast but narrow"
"webpack 5": [0.22, 0.95]
"Vite 7": [0.72, 0.70]
"Vite 8 + Rolldown": [0.88, 0.68]
"Rspack 2.x": [0.90, 0.88]
"Rsbuild 2.x": [0.85, 0.62]
The three axes really are different:
- When work executes: webpack and Rspack do everything before serving; Vite does it gradually, request by request, in dev. This is the biggest difference in experience, and no amount of engine speed can make up for it.
- Engine language: webpack is single-threaded JavaScript. Vite (from 8) is Rust with one unified data representation across parse, transform and bundle. Rspack is multi-threaded Rust, but pays a tax at the Rust-JS boundary whenever JS loaders/plugins are used.
- Ecosystem contract: Rspack chose webpack API compatibility (swap the engine, keep the config); Vite chose its own API (change how you think); Rsbuild chose its own API at the upper layer but leaves the door open down to the Rspack layer.
On the trade-offs per tool:
- webpack 5: slowest, finest control over output; Module Federation; an ecosystem with no rival. Pick it when you depend on a plugin that only exists for webpack, or need chunk splitting at a level no other tool allows.
- Vite: the best dev experience for most projects, a good-enough prod build, an easy plugin API. The thing to weigh is that the community plugin count is still an order of magnitude below webpack.
- Rspack/Rsbuild: the cheapest migration path from webpack, and speed close to Vite. The thing to weigh is that you’re depending on a younger project, so read the changelog before moving up a major version.
7. How to measure without fooling yourself
Vendor benchmarks always use a 10,000-component project, standalone, with few plugins. Your real repo has CSS Modules, a monorepo, internal plugins, and a tsconfig with project references. The numbers will differ, usually far less impressive.
Four metrics worth measuring before and after, in priority order:
- Dev server cold start (cold machine, cache cleared first): this decides the day-to-day experience.
- HMR p95, not the average: the tail is what breaks your focus. A p95 five times the average means some module is breaking the HMR boundary.
- Production build with and without cache, measured separately: if CI always builds clean, the “cached” number is a pretty but meaningless figure.
- Peak RAM: Rust bundlers are usually faster but also eat more RAM; on a 16GB dev machine or a small CI runner, this can be a real limit.
And one warning: don’t measure by “how long the build command takes”. Measure by time to first render in dev, because that’s what developers actually feel. I once saw a migration cut build time by 60% while dev cold start got slower, because initializing the cache took longer.
8. Decision tree
flowchart TB
A["Start"] --> B{"Depend on a plugin<br/>that only exists for webpack?"}
B -->|"yes"| C["Rspack: config adapter<br/>keep the webpack contract"]
B -->|"no"| D{"Is the repo a<br/>large legacy system?"}
D -->|"yes"| E{"Is the webpack config<br/>deeply tuned?"}
E -->|"yes"| F["Rspack directly"]
E -->|"no"| G["Rsbuild: defaults + tools.rspack"]
D -->|"no, new project"| H["Vite 8 + Rolldown"]
C --> I["Measure cold start, HMR p95,<br/>prod build with/without cache, RAM"]
F --> I
G --> I
H --> I
I --> J{"Goal met?"}
J -->|"not yet"| K["Inspect the module graph<br/>before switching tools again"]
J -->|"yes"| L["Done"]
The last stop in this tree matters more than it looks: if you swap bundlers and it’s still slow, the problem usually isn’t the bundler. A barrel file exporting 500 things, a plugin that scans the whole repo on every build, or a circular import pulling the whole graph into one chunk. Switching tools won’t fix any of those.
9. Migration in practice
webpack → Rspack. Don’t rewrite the config. Create an adapter that keeps the original webpack config and only overrides the incompatible parts, so the diff between the two models always stays visible. The two groups you’ll most often have to fix are plugins that use webpack’s internal hooks, and the CSS pipeline if you’re on older loaders. A note on state: a plugin that keeps state on the Compilation will work in watch mode, while state on the compiler you have to reset yourself.
webpack → Rsbuild. This one is different: you’re dropping most of the config rather than porting it. Babel goes away, since Rsbuild uses SWC and downlevels on its own according to browserslist, so @babel/preset-env is no longer needed. Whatever config you genuinely still need goes into tools.rspack, because Rspack and webpack config are nearly equivalent. The cost is losing control in places you used to tune by hand.
Vite → Vite 8. Two steps: rolldown-vite on Vite 7 first, run the test suite and a real build, and only then move to Vite 8. Audit plugins before upgrading, prioritizing ones that are still actively maintained, and watch for plugins that use this.environment or other per-environment APIs, since that’s where behavior is most likely to change.
Post-migration checklist: the build output has to be equivalent at the chunk and size level (a few percent off is normal, double means tree-shaking or splitChunks is behaving differently); source maps still map correctly; and HMR doesn’t lose state in places where it used to keep it.
10. Wrapping up
No tool is “the fastest” in an absolute sense, because these three don’t solve the same problem in the same way. webpack optimizes for control, Vite optimizes for the dev loop, Rspack optimizes for switching cost, and all three are converging on the same point: a Rust engine, compatibility with the old APIs, one engine for both dev and prod.
What’s worth taking from the history of these three tools is this: Vite needed eight versions to fix the cost of its two-engine architecture; Rspack had to design an entire binding layer to pay the tax of the language boundary; and webpack is adding TypeScript and HTML to core the way plugins have done for ten years. No tool won by being right from the start. They won by fixing their own weaknesses before a rival got around to the same thing.
So the right question isn’t “which tool is fastest”, but “which cost am I paying every day, and which tool reduces exactly that cost”. For me, the answer sits in cold start and HMR p95, two numbers no vendor benchmark can measure for you.
Keep reading
Need a developer for a contract or freelance project?
I take on fullstack projects — Go backends, React/Next.js frontends, or both ends. Send a few lines about the scope and timeline and I'll reply within 48 hours.