Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

Webpack 5 is not slow because its algorithm is wrong. It is slow because JavaScript is a poor fit for the parallelism and memory access patterns that module bundling demands. Every module resolution, dependency graph traversal, and code transformation happens in a single-threaded JavaScript runtime that cannot share memory across workers without serialization overhead.

Rspack addresses this at the language level. It reimplements the webpack compilation model in Rust, preserving the familiar config surface area while moving the hot paths into native code. The result is cold build times that are 5-10x faster than webpack 5 on medium-to-large codebases, with enough API compatibility that most webpack configs migrate with minor changes.

This article walks through how that works internally: the module graph, the Rust compilation pipeline, the compatibility shim for JavaScript plugins and loaders, and what breaks at the seams.

The Module Graph in Rust

Webpack’s conceptual model is a directed acyclic graph of modules connected by dependencies. Rspack preserves that model exactly, but builds and traverses the graph in Rust using a structure called ModuleGraph.

Each node in the graph is a ModuleIdentifier keyed by a combination of the resource path and query string. Edges are Dependency objects that carry the dependency type (static import, dynamic import, CommonJS require, CSS @import, etc.), the resolved module identifier, and the export names referenced at each call site.

The critical difference from webpack is that Rspack constructs the graph using a parallel work-stealing scheduler. When a module is resolved, its dependency list is extracted immediately, and each dependency is enqueued as an independent task. Worker threads pick tasks off the queue concurrently, constrained only by actual data dependencies rather than JavaScript’s event loop. In a 2000-module codebase, the module graph phase that takes 4 seconds in webpack 5 completes in under 600ms in Rspack on an 8-core machine.

The graph structure is shared across Rust threads using Arc<RwLock<ModuleGraph>>. Write access is taken during the make phase when modules are being added. Read access dominates during the seal phase when the graph is traversed to compute chunks. The lock granularity is coarse by design; fine-grained locking adds contention overhead that outweighs the parallelism benefit for this particular workload.

Module Resolution

Rspack ships its own resolver written in Rust, derived from the enhanced-resolve algorithm that webpack uses. The resolver handles the full Node.js resolution algorithm including package.json exports field, conditional exports, browser field remapping, symlinks, and alias configuration.

The resolver maintains a per-thread cache backed by a DashMap (a concurrent hashmap) keyed by (context, request) pairs. This avoids redundant filesystem calls when multiple modules import the same specifier from nearby directories, which is common with shared utilities and design system components.

One practical detail: Rspack’s resolver does not call into Node.js at any point during resolution. There is no require.resolve fallback. If a webpack config relied on require.resolve inside a resolve plugin to locate a resource, that plugin will not run in Rspack’s fast path. It runs in the JavaScript interop layer instead, which adds overhead.

The SWC Transformation Pipeline

After a module is resolved, its source code needs to be parsed, transformed, and optionally transpiled. Rspack uses SWC for this, the same Rust-native parser and transformer that Turbopack uses.

The transformation pipeline for a typical TypeScript + React file looks like this:

  1. SWC parses the source into an AST (Abstract Syntax Tree) in Rust memory
  2. The AST is walked to collect dependency descriptors (import specifiers, dynamic import expressions, require calls)
  3. If the module is TypeScript or JSX, SWC transforms the AST in place: stripping types, transforming JSX to React.createElement or the automatic JSX runtime
  4. SWC emits JavaScript source and a source map
  5. The emitted JavaScript is stored as the module’s generated code in the ModuleGraph

Because the AST never crosses the Rust/JavaScript boundary during steps 1 through 5, there is no serialization cost. By contrast, when a webpack loader calls this.getOptions() or emits source, data crosses the JavaScript heap boundary. In a codebase with 1500 TypeScript files, eliminating that boundary crossing alone accounts for 30-40% of the build time difference.

Here is what the SWC configuration looks like when you control it explicitly in rspack.config.ts:

import { defineConfig } from "@rspack/cli";

export default defineConfig({
  entry: "./src/index.ts",
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: {
          loader: "builtin:swc-loader",
          options: {
            jsc: {
              parser: {
                syntax: "typescript",
                tsx: true,
              },
              transform: {
                react: {
                  runtime: "automatic",
                  development: process.env.NODE_ENV === "development",
                  refresh: process.env.NODE_ENV === "development",
                },
              },
              target: "es2022",
            },
          },
        },
      },
    ],
  },
});

The builtin:swc-loader prefix tells Rspack to route this rule through the native SWC path rather than the JavaScript loader interop layer. If you use swc-loader without the prefix, you get the npm package running in Node.js, which is significantly slower. Confusing a team member with this detail is one of the most common performance regressions when teams first migrate.

The Webpack Plugin and Loader Compatibility Layer

Rspack’s biggest technical bet is preserving the webpack plugin API. Webpack’s plugin system is built on a hook library called Tapable. Plugins call compiler.hooks.someHook.tap(...) to register callbacks that fire at specific points in the compilation lifecycle.

Rspack re-implements the same hook names, firing them at semantically equivalent points in the Rust compilation cycle. The implementation uses a JavaScript interop layer powered by Napi-rs, which provides zero-copy access to certain Rust data structures from JavaScript while allowing JavaScript callbacks to mutate compilation state.

The hook sequence is largely preserved:

compiler.hooks.beforeRun
compiler.hooks.run
compiler.hooks.beforeCompile
compiler.hooks.compile
compiler.hooks.make         ← module graph construction
compiler.hooks.afterCompile
compiler.hooks.emit         ← asset emission
compiler.hooks.afterEmit
compiler.hooks.done

Most plugins that tap emit or done to copy files, generate manifests, or write stats work without modification. Plugins that manipulate the module graph directly during make have a harder time because the Rust module graph is not a plain JavaScript object. They must go through the Napi-rs binding, which exposes a subset of the graph API.

The compilation.modules iterable, for example, is a JavaScript-side view backed by Rust memory. Iterating it in a plugin works fine. Calling methods that write back to the graph triggers synchronization overhead.

Incremental Compilation and the Make Phase

Rspack implements incremental compilation using a module-level dirty tracking system. When a file changes on disk (detected via a file system watcher using inotify on Linux, kqueue on macOS, or ReadDirectoryChangesW on Windows), the corresponding module is marked dirty.

The make phase re-runs only for dirty modules and their dependents. The dependency chain is computed by walking the ModuleGraph in reverse: given a dirty module M, find all modules that import M, and mark them as candidates for re-evaluation if their exports might have changed.

Whether re-evaluation actually propagates depends on what changed. If only the implementation of a function changed and its exported interface (name and type signature) remained the same, Rspack can stop propagation at M’s boundary. This is structurally similar to how Turbopack uses function-level memoization with Vc<T> but coarser: Rspack tracks module-level export interfaces rather than function-level pure computations.

The persistent cache stores module build results keyed by a content hash of the module source plus its configuration inputs (loader options, mode, resolve aliases). On the next cold start, Rspack reads these entries from disk and skips rebuilding any module whose inputs have not changed. The cache is stored in node_modules/.cache/rspack by default.

export default defineConfig({
  cache: {
    type: "filesystem",
    cacheDirectory: path.resolve(__dirname, ".rspack-cache"),
    buildDependencies: {
      config: [__filename],
    },
  },
});

The buildDependencies.config array tells Rspack to invalidate the entire cache when the config file itself changes. Without this, a config change would not flush cached module results, leading to stale builds that are difficult to debug.

Tree Shaking via Side Effects Analysis

Rspack implements tree shaking in two passes.

The first pass uses the sideEffects field in package.json. If a package declares "sideEffects": false, Rspack can eliminate any re-export from that package that is not ultimately reachable from an entry point. This is the coarse-grained pass and the one that does most of the work for design systems and utility libraries.

The second pass is intra-module dead export elimination. Rspack builds an export graph for each module, tracking which exports are consumed by other modules. Exports with no consumers are flagged and dropped during code generation. SWC handles the actual removal, since the AST is already in Rust memory.

A subtlety: this analysis is only reliable for ES module syntax (import/export). CommonJS modules with dynamic property access on module.exports or exports are treated as opaque: all their exports are retained. In a mixed codebase with legacy CommonJS packages, this limits tree shaking effectiveness. The fix is to use packages that publish ESM alongside CJS, or to add sideEffects: false annotations to your own packages.

Code Splitting and Chunk Optimization

Rspack’s chunk graph is computed after the module graph is sealed. The algorithm is equivalent to webpack’s SplitChunksPlugin, which groups modules into chunks based on shared ancestors and size thresholds.

The default configuration produces:

  • One chunk per entry point
  • Async chunks for each dynamic import boundary
  • Shared chunks extracted when a module is referenced from more than one parent chunk and exceeds the minimum size threshold
export default defineConfig({
  optimization: {
    splitChunks: {
      chunks: "all",
      minSize: 20_000,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          name: "vendors",
          chunks: "all",
          priority: -10,
        },
        react: {
          test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
          name: "react-vendor",
          chunks: "all",
          priority: 20,
        },
      },
    },
  },
});

This configuration is structurally identical to webpack 5’s. That is intentional. Teams migrating from webpack copy their splitChunks configuration and it works.

The chunk optimization phase runs in Rust. The algorithm iterates the chunk graph, applies cache group rules, and produces the final chunk manifest. For a 300-chunk production build, this phase takes under 200ms in Rspack versus 2-3 seconds in webpack 5 because it avoids repeated JavaScript-to-JavaScript serialization of large module sets.

HMR Architecture

Rspack’s HMR implementation follows the same protocol as webpack’s: the dev server maintains a WebSocket connection to the browser client, and when a module changes, the server sends an update manifest followed by the updated module chunks.

The update manifest lists the changed modules by their module identifier hash. The browser client receives the manifest, fetches the update chunks via HTTP, and calls module.hot.accept handlers in topological order starting from the changed module and walking up to the first accepting boundary.

The speed advantage in Rspack comes from the incremental make phase: because Rspack re-runs only the dirty module subgraph and regenerates only the affected chunks, the time between file save and HMR update delivery is typically 50-150ms for a single file change in a large application. In webpack 5, the equivalent cycle is often 800ms to several seconds depending on the size of the affected chunk.

React Fast Refresh is supported via @rspack/plugin-react-refresh:

import ReactRefreshPlugin from "@rspack/plugin-react-refresh";

const isDev = process.env.NODE_ENV === "development";

export default defineConfig({
  plugins: [isDev && new ReactRefreshPlugin()].filter(Boolean),
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: {
          loader: "builtin:swc-loader",
          options: {
            jsc: {
              transform: {
                react: {
                  runtime: "automatic",
                  development: isDev,
                  refresh: isDev,
                },
              },
            },
          },
        },
      },
    ],
  },
});

The plugin injects the React Refresh runtime and wraps module code with $RefreshReg$ and $RefreshSig$ calls. The underlying HMR protocol is unchanged from webpack.

Production Considerations

Plugin compatibility gaps. The most common migration blockers are plugins that use compiler.hooks.normalModuleFactory or compilation.hooks.buildModule to intercept individual module builds. These hooks exist in Rspack, but the NormalModule objects passed to them are Napi-rs proxies rather than plain JavaScript objects. Plugins that destructure module.loaders or write to module._source directly fail silently or throw. Audit your webpack plugins against the Rspack plugin compatibility matrix before committing to a migration.

Loader interop overhead. Any loader that runs through the JavaScript interop layer is slower than a native Rspack builtin. If you have custom loaders that are performance-critical, rewriting them as Rspack builtins in Rust gives the full speedup. For loaders that are infrequently invoked (image transforms, font processing), JavaScript interop is acceptable.

Memory characteristics. Rspack’s Rust allocator holds the module graph, generated code, and chunk manifests in native memory. RSS (resident set size) for large builds can be 1-2GB, which is comparable to webpack 5. The difference is that Rspack does not keep large JavaScript heap allocations alive in addition to native memory, so GC pause pressure on the Node.js process is negligible.

Persistent cache invalidation. The cache key includes the Rspack version. Upgrading Rspack invalidates all persistent cache entries. In CI pipelines, restore the cache keyed by package-lock.json hash and Rspack version, then let Rspack populate it on the first run. Subsequent runs with unchanged dependencies hit the cache for most modules.

SWC and Babel plugin incompatibility. Rspack uses SWC, not Babel. Any Babel plugin you relied on (custom decorators transforms, specific macro plugins) needs an SWC equivalent or must be wrapped in a babel-loader rule, which removes the native transformation speedup for affected files. Projects with heavy Babel plugin usage see more limited gains than projects that were already on minimal Babel configs.

Output format. Rspack targets the same chunk format as webpack 5: a custom runtime embedded in the main chunk with __webpack_require__ as the module system. If your deployment pipeline processes webpack output (inline chunk manifests, specific asset naming conventions), Rspack’s output is a drop-in replacement. If you were using webpack’s output.module: true for native ESM output, Rspack’s ESM output support is present but less battle-tested than the classic runtime format.

Migrating from Webpack

The migration path is direct for most projects:

  1. Replace webpack and webpack-cli with @rspack/core and @rspack/cli
  2. Rename webpack.config.js to rspack.config.js (or keep the name and pass --config)
  3. Replace babel-loader with builtin:swc-loader for TypeScript and JSX
  4. Test each webpack plugin against the compatibility matrix and find replacements for any that use unsupported hook internals
  5. Run a side-by-side output comparison using a tool like webpack-bundle-analyzer on both outputs to verify chunk contents match

Step 4 is where migrations stall. A typical enterprise webpack config accumulates a dozen plugins over time. Two or three of them usually need attention. The rest work without modification.

Tradeoffs Comparison

DimensionRspackWebpack 5Vite 5Turbopackesbuild
Compilation modelRust, parallel module graphJS, single-threadedNative ESM in dev, Rollup in prodRust, function-level memoizationGo, parallel
Cold build speed5-10x faster than webpackBaselineFast (no bundle in dev)Comparable to RspackFastest for pure JS/TS
HMR speed50-150ms500ms-3s10-50ms (native ESM)30-100msN/A (no HMR runtime)
Webpack plugin supportHigh (95%+ of common plugins)FullLow (Rollup plugin API)Low (early ecosystem)None
Tree shakingES module side effects analysisES module side effects analysisRollup-based, strongES module side effects analysisStrong for pure code
Persistent cacheYes, filesystem-basedYes, filesystem-basedYes (Vite 5 dep pre-bundling)Yes, content-addressableNo (stateless by design)
Production readinessHigh (ByteDance, major apps)Very high (decade of usage)High (mainstream)Medium (Next.js 13+ only)Medium (limited chunk graph)
LanguageRust core, JS config/pluginsJavaScriptJavaScriptRust core, JS configGo
Sweet spotWebpack migrations, large TS appsLegacy projects, rich plugin needsGreenfield SPAs without webpack dependencyNext.js apps on Vercel infrastructureLibraries, CI type-check pipelines

Closing

Rspack’s design thesis is that the webpack compilation model is correct but the execution environment is wrong. Moving the graph traversal, dependency resolution, and AST transformation into Rust while keeping the plugin API in JavaScript is a reasonable decomposition: it gives the performance gains where the CPU time actually goes, while preserving the ecosystem that makes webpack configurations portable.

The seams show when plugins need to manipulate internals that were never part of the stable API. That was true in webpack too, but JavaScript transparency made it easier to paper over. In Rspack, crossing the Napi-rs boundary makes the contract explicit.

For teams running webpack 5 on projects with more than a few hundred modules, the migration is almost always worth the audit cost. The build time reduction is not marginal.

More in Web Engineering

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.

How Next.js Works Internally: The Compilation Pipeline, RSC Protocol, and Caching Architecture From Request to Render
Web Engineering ·

How Next.js Works Internally: The Compilation Pipeline, RSC Protocol, and Caching Architecture From Request to Render

A deep dive into the internal architecture of Next.js App Router. Covers the SWC/Turbopack compilation pipeline, the RSC wire protocol, the four-layer caching architecture, rendering strategies, middleware execution, Server Actions, and the self-hosted vs managed deployment tradeoffs.