For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt, and this page is available as Markdown at /api/javascript-api/architecture.md.
close

JavaScript API architecture

Rspack exposes a webpack-compatible JavaScript API through @rspack/core, while most compilation work runs in Rust. The two layers communicate through a Node-API binding.

What is a native-backed object?

An ordinary JavaScript object stores its data directly in the JavaScript engine. For example, reading the name property of { name: 'main' } does not require a call into Rust.

Rspack's Compilation, Module, Chunk, and graph objects work differently. Most of their compilation data is stored in Rust, while the JavaScript object usually stores only an identifier or internal handle used to locate that data. When a plugin reads certain getters or calls certain methods, Rspack accesses Rust through the Node-API binding and converts the result back into a JavaScript value. These objects are called native-backed objects.

Ordinary JavaScript object

  Read property ──────> Data in JavaScript object ──────> Result available directly


Native-backed object

  Read getter          JavaScript object              Node-API binding
  or call method ────> (identifier / internal handle) ────> (cross-runtime access)


                                                    Rust Compiler / Compilation
                                                    (compilation data)


  Receive JavaScript value <────── Convert data <──────── Node-API binding

The two kinds of objects can use exactly the same JavaScript syntax while behaving differently:

Object typeWhere the data is storedWhat happens on property access or method callLifetime
Ordinary JavaScript objectJavaScript memoryRead directly without crossing the bindingData normally exists while referenced
Native-backed objectPrimarily in RustMay read or modify data through the Node-API bindingLimited by its compiler or compilation

A native-backed object can contain both kinds of properties:

  • immutable data such as strings and numbers may be copied into JavaScript when the object is created, so reading it later does not access Rust;
  • getters and methods may access Rust on every call to retrieve current compilation data or perform an operation.

Keeping the JavaScript object alive does not guarantee that it still refers to data from the build that created it. After a rebuild, a getter or method may retrieve data from the latest Rust compilation, or it may throw because its target no longer exists.

This implementation model has two direct consequences:

  1. Lifetime: A native-backed object reliably refers to the intended Rust data only within the compiler, compilation, or hook execution scope that produced it.
  2. Performance: Reading a getter or calling a method may convert data between JavaScript and Rust. Repeating the same access in a loop can accumulate significant cost.

If data is needed later, copy strings, numbers, or ordinary JavaScript objects while the corresponding build is still active instead of retaining the native-backed object itself.

Compiler lifecycle

Creating an @rspack/core Compiler does not immediately create the corresponding native compiler. Rspack first normalizes options and runs plugin apply calls in JavaScript. It creates the Rust compiler on demand when a build API such as run() or watch() is called for the first time.

After the native compiler has been created, the following information has already been finalized and passed to Rust:

  • normalized options and builtin plugins;
  • registration functions that connect JavaScript hooks to the native hook adapter;
  • file systems and resolver factories used for cross-language calls.

Do not assume that arbitrary changes to compiler.options will still be synchronized to Rust after the native compiler has been created. Apply plugins and finish configuration before the first call to run() or watch().

Difference from webpack's object model

A Compiler instance has only one active native Compilation at a time. Every build, including each watch rebuild, replaces that native compilation.

In development, some webpack plugins continue to use a JavaScript Compilation after that build's done hook has finished. A watch rebuild may already have started before this later work runs, creating a race in which the plugin holds a Compilation from the previous build. To remain compatible with this common pattern, Rspack treats every JavaScript Compilation as a view of the compiler's current native compilation. Getters and methods on the old object can therefore read or modify the latest build instead of throwing. Keeping that object does not preserve the previous build.

In watch mode, use the Compilation and native-backed objects provided to the current build's hooks. If historical data is required, copy it into ordinary JavaScript values during the corresponding build.

When a compiler is no longer needed, call close() and wait for its callback. This waits for the current compilation to finish and releases the native resources and JavaScript callbacks owned by the compiler.

Compilation and object lifetimes

Every build, including each watch rebuild, creates a new Compilation. Objects such as Module, Chunk, and graph objects obtained from a Compilation do not copy all of their data into JavaScript. Reading some properties or calling some methods still requires Rspack to retrieve data from the current Rust compilation through Node-API.

Follow these rules:

  • Use a Compilation and its Module, Chunk, graph, dependency, and block objects only in hooks and callbacks for the current build.
  • Unless the API documentation explicitly promises a longer lifetime, do not retain these objects after the hook finishes or into the next build.
  • If information is needed later, save JavaScript-owned strings or numbers such as identifiers, names, paths, and hashes, or convert the data into an ordinary JavaScript object.
  • After a watch rebuild, obtain objects again from the new Compilation instead of reusing objects saved from the previous build.

Hook and callback execution

The Rust compiler can use multiple threads to run several independent compilation tasks at the same time. JavaScript hooks, loaders, and other callbacks cannot run directly on those Rust threads. They must be scheduled back into the JavaScript environment in which the Rspack compiler was created.

User code in one JavaScript environment runs on one JavaScript thread. Even if multiple Rust threads trigger callbacks at the same time, those callbacks enter a queue and are executed one by one on the JavaScript thread:

Rust threads run in parallel                 JavaScript runs on one thread

  Compilation task A ─┐
  Compilation task B ─┼─> Cross-thread callback queue ─> Run callbacks one by one
  Compilation task C ─┘                                      │
                                                             └─> Return result
                                                                 └─> Corresponding Rust task continues

The cost of hooks and callbacks is therefore not limited to Node-API conversion and thread scheduling. More importantly, they introduce a serial execution point: multiple Rust tasks that could otherwise run in parallel may all wait for the JavaScript queue. The more frequently a callback runs and the longer it takes, the more it can reduce the benefits of Rust parallelism. Rust work before and after the callback can still run in parallel, but the part that depends on the JavaScript result is limited by single-threaded throughput.

Why loaders are especially likely to become a bottleneck

Rspack can process several files at the same time, but JavaScript loaders must run on the JavaScript thread. When several files need loader processing at once, their JavaScript loaders queue up, and the Rust compilation tasks waiting for those results pause at that boundary. The heavier the JavaScript loader and the more often it runs, the closer the overall build gets to serial execution.

When equivalent functionality is available, prefer an Rspack builtin loader. For example:

Builtin loaders run on the Rust side, reducing JavaScript queueing and cross-language data conversion while making better use of Rspack's parallelism.

Loader source ownership

The native loader runner keeps its LoaderContext in a Box. Throughout the loader chain, content is stored as Content::String or Content::Buffer, with the source map stored separately. This preserves the input type for each loader. NormalModule constructs the final source after the loader chain finishes.

When execution reaches JavaScript, a native JsLoaderContext class directly owns an Option<Box<LoaderContext>> for that invocation. Its getters materialize content and source maps only when the JavaScript runner reads them. The JavaScript wrapper caches each getter result, including missing values, for that entry. The same wrapper resets its read cache on the next entry; output produced in JavaScript takes precedence over cached input.

The JavaScript loader-context wrapper reads one plain JsLoaderContextState object (#[napi(object)]), accesses its mutable fields through JavaScript getters and setters, and passes the same state object back in one call before returning. Loader metadata is read once when the wrapper is created; per-loader data and execution flags travel in the state object. Reused loader objects read the latest state after each native loader. The state carries an optional output only when JavaScript produces one. A pitch that produces no output leaves native content and its source map unchanged.

Each native loader context contains a typed LifecycleGuard from rspack_napi. The guard allocates a process-unique, incrementing ID. ThreadLocalReference caches the corresponding JsLoaderContext by this ID on its JavaScript thread, separately for each N-API environment. Hooks use the same identity without moving the Box. A JavaScript WeakMap associates the class with its wrapper and webpack-compatible this object, so neither needs to travel in the mutable state object.

When the native context is consumed into its loader result, its guard drops and queues reference cleanup on each registered JavaScript environment. Cleanup notifications are batched and wake the JavaScript thread even if no more loaders run. Environment shutdown also releases cached references. Removing a reference permits garbage collection; a class retained by user code still follows the native access-window checks.

Before loaders run, Rust loader hooks borrow &mut LoaderContext. A JavaScript loader hook receives a JsLoaderHookContext snapshot and returns only its state; the native runner keeps ownership of the Box throughout. Only the JavaScript loader runner takes and returns the Box, together with the loader result. Hook-installed properties and closures survive later builtin and JavaScript loaders through a shared JavaScript wrapper, whose captured execution state is refreshed on each entry.

Native loader: String/Buffer + separate source map
  ↓ move boxed LoaderContext
JsLoaderContext: state snapshot + lazy content/source-map getters

JavaScript loader-context wrapper: getters/setters access state
  ↓ pass the whole state object back
Native runner: recover boxed LoaderContext and continue

JavaScript returns the same class instance on success or with a captured error. The return-value conversion takes its boxed context on the JavaScript thread before moving it back to the native runner. The instance is left with None, revoking native access until the next loader-runner entry reattaches the Box, without a shared ownership slot or per-access locking. This is an internal bridge; loaders continue to use the webpack-compatible this context, string/Buffer content, source-map objects, and callbacks.

Efficient usage

Read only the assets you need

compilation.assets looks like an ordinary JavaScript object, but its asset contents are provided on demand by the native compilation. Object.keys(assets) retrieves only asset names. Rspack retrieves the corresponding Source from Rust through the binding only when assets[name] is read.

If only some assets are needed, the following pattern is inefficient:

Inefficient
compilation.hooks.processAssets.tap('ExamplePlugin', (assets) => {
  for (const [name, source] of Object.entries(assets)) {
    if (name.endsWith('.js')) {
      consumeJavaScriptAsset(name, source);
    }
  }
});

Object.entries(assets) reads the Source of every asset before the loop starts. Even if the loop uses only a few assets, this causes unnecessary cross-language communication and memory usage.

Use Object.keys() to filter asset names first, then read only the required sources:

Recommended
compilation.hooks.processAssets.tap('ExamplePlugin', (assets) => {
  for (const name of Object.keys(assets)) {
    if (!name.endsWith('.js')) {
      continue;
    }

    const source = assets[name];
    consumeJavaScriptAsset(name, source);
  }
});

If every asset is needed, using Object.entries() or Object.values() does not introduce unused reads.

Read once in a loop

Read and retain data obtained from Rust during a single pass:

compilation.hooks.processAssets.tap('ExamplePlugin', () => {
  const moduleInfo = [];

  for (const module of compilation.modules) {
    const size = module.size();
    moduleInfo.push({
      identifier: module.identifier(),
      size,
    });
  }

  consumeModuleInfo(moduleInfo);
});

module.size() retrieves data from Rust through the binding. Keeping size in a local variable avoids calling module.size() again in later logic.