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.
The two kinds of objects can use exactly the same JavaScript syntax while behaving differently:
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:
- Lifetime: A native-backed object reliably refers to the intended Rust data only within the compiler, compilation, or hook execution scope that produced it.
- 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().
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
Compilationand itsModule,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
Compilationinstead 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:
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:
- use
builtin:swc-loaderfor JavaScript and TypeScript transforms; - use
builtin:lightningcss-loaderfor CSS transforms.
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.
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:
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:
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:
module.size() retrieves data from Rust through the binding. Keeping size in a local variable avoids calling module.size() again in later logic.

