How to Use Hook Filters to Reduce FFI Costs in Rolldown Plugins
In March 2026, Vite 8 was officially released, bringing significant changes to the build environment we had grown comfortable with. The dual-bundler structure — esbuild for the dev server and Rollup for production builds — was replaced by a single Rust bundler called Rolldown. The Vite team's official blog post Announcing Vite 8 showcases build time improvements for large-scale projects (see the original post for specific numbers and measurement conditions).
However, the answer to "so how do I actually use Rolldown plugins to get that performance?" turns out to be harder to find than expected. It's clear the API is similar to Rollup plugins, but how exactly Rust and JS connect, why hook filters are required, and where performance leaks — this post digs into all of that.
Rolldown 1.0 officially launched in May 2026 with a SemVer stable API, making now the right time to understand and experiment with the plugin architecture.
Why Rolldown Became Vite's Bundler
Background: A Tale of Two Bundlers
If you looked inside Vite 7 and earlier, something odd stood out. The dev server used esbuild for pre-bundling, while vite build used Rollup — a dual-bundler structure. This regularly caused environment mismatch bugs where something that worked in development behaved differently in production builds. I personally spent quite a long time debugging issues before understanding why dev and prod behavior differed.
Rolldown is a bundler built by VoidZero (the company Evan You founded) to replace both roles with a single Rust implementation. Internally, a Rust component called Oxc (Oxidation Compiler) handles parsing, transpilation, minification, and source map generation.
The critical part is that FFI boundary. Rolldown creates bindings between Node.js and Rust via Napi-rs, and JS plugin hooks execute by crossing that boundary. The cost of crossing this boundary is the most important thing to manage in Rolldown plugins.
The Intent Behind Rollup-Compatible Design
Rolldown's plugin API is intentionally identical to Rollup's. Hook names and signatures like resolveId, load, transform, renderChunk, and buildEnd are preserved as-is. This means most Rollup plugins work without major modifications, though plugins that depend on Rollup's internal private APIs or non-standard context may require separate changes. For the actual compatibility status, it's safest to check the documentation for the plugins you're using and the compatibility notes in the Vite 8 official blog.
How Plugins Connect to the Rust Pipeline
The Execution Flow of JS Plugins
Rolldown's JS plugin hooks execute in a single-threaded JavaScript runtime. The Rust core can queue and batch hook calls for multiple modules concurrently, but since there is only one JS event loop, response handling itself is serialized. In other words, it helps to understand the two layers separately: "request scheduling is parallel, but JS-side execution is single-threaded."
sequenceDiagram
participant R as Rust Core
participant FFI as Napi-rs Boundary
participant JS as JS Runtime
R->>R: Parse Modules A B C in Parallel
par Queue Hook Calls
R->>FFI: Request transform for Module A
R->>FFI: Request transform for Module B
end
FFI->>JS: Execute Handler Single-threaded
JS-->>FFI: Result for A
JS-->>FFI: Result for B
FFI-->>R: Deliver Results
Note over R,JS: Scheduling can be parallel; execution is JS single-threadedThe problem is that as the number of modules grows, these FFI round-trips accumulate. To minimize this overhead, Rolldown introduced Hook Filters. The background and design intent are well explained in the Why Plugin Hook Filter document.
Hook Filters: The Most Important Optimization
The filter option is a mechanism that lets Rolldown pre-screen on the Rust side before passing a plugin handler to JS. Modules that don't match the filter condition never enter the JS runtime at all — the FFI cost simply doesn't occur.
// Bad pattern: crosses the FFI boundary for every file
export function myTransformPlugin() {
return {
name: 'my-transform',
transform(code, id) {
if (!id.endsWith('.ts') && !id.endsWith('.tsx')) return null
return transformMyCode(code, id)
}
}
}// Improved pattern: only .ts/.tsx files are passed to JS
export function myTransformPlugin() {
return {
name: 'my-transform',
transform: {
filter: {
id: /\.(ts|tsx)$/,
},
handler(code, id) {
return transformMyCode(code, id)
}
}
}
}Both snippets produce the same result, but the first one crosses the FFI boundary for every file — .vue, .css, .json, and so on. The second only runs the JS handler for .ts/.tsx files. If you carry over Rollup habits as-is, it's easy to miss this at first (as I did), but the difference becomes noticeable the larger the repository.
The filter accepts two axes: id (file path pattern) and moduleType. The exact enumeration values accepted by moduleType and the rules for mapping extensions and content types to those values can shift between versions, so it's safest to verify against the Rolldown official docs for the version you're using. Matching behavior can differ depending on whether you're filtering by extension or by the actual loaded type.
Supported filter kinds also vary by hook. The renderChunk hook supports code filters as of 2026 (see PR #4351), which is useful when you want to apply a hook only to chunks matching a specific file pattern.
Plugin Authoring Scenarios
Scenario 1: A Simple Code Transform Plugin
A plugin that redirects certain import specifiers to different paths. Since resolveId is called for every module resolution, it's good practice to narrow down the target specifiers quickly and return early.
export function aliasRewritePlugin(aliases) {
return {
name: 'alias-rewrite',
// source: the specifier string used in the import statement (e.g. 'foo/bar')
resolveId(source) {
const match = aliases[source]
if (match) return match
return null
}
}
}The first parameter is named source rather than id intentionally. Per Rollup/Rolldown documentation, the first argument to resolveId is an unresolved module specifier, which is distinct from the id (resolved path) received by load(id) after resolution. It's a one-word difference, but conflating the two makes it easy to make mistakes when adding more hooks later.
Whether filter.id is supported for resolveId can vary by hook, so checking the release notes for your Rolldown version is recommended.
Scenario 2: A Virtual Module Plugin
A pattern for creating modules that are dynamically generated at build time. For example, importing virtual:app-config produces a module that exports a config object based on environment values.
const VIRTUAL_ID = 'virtual:app-config'
const RESOLVED_ID = '\0virtual:app-config'
export function virtualConfigPlugin(config) {
return {
name: 'virtual-app-config',
resolveId(source) {
if (source === VIRTUAL_ID) return RESOLVED_ID
return null
},
load: {
filter: {
id: /^\0virtual:app-config$/,
},
handler(id) {
return `export default ${JSON.stringify(config)}`
}
}
}
}The \0 prefix is a Rollup-lineage convention that Rolldown uses to distinguish virtual modules from real files. By putting the virtual module ID in the load hook's filter, you can completely block FFI calls for real files.
Scenario 3: Post-Build Processing with the buildEnd Hook
export function reportPlugin() {
return {
name: 'build-report',
// error?: Error — only passed on failure
buildEnd(error) {
if (error) {
console.error('Build failed:', error.message)
return
}
console.log('Build complete')
}
}
}Build-level hooks like buildEnd are called only once regardless of module count, so FFI cost is essentially a non-issue. Documenting that error is optional in the signature reduces type confusion.
Scenario 4: Multi-Bundler Support with unplugin
If you want to use a Rolldown-specific plugin in Rollup, Webpack, Rspack, and esbuild as well, unplugin is a great option. Since unplugin's adapters change incrementally over time, check the unplugin release notes to see which bundler adapters are currently exposed. As of this writing (2026), the stably available adapters are .vite(), .rollup(), .webpack(), .esbuild(), and .rspack().
Because Rolldown uses the Rollup-compatible API directly, even without a dedicated adapter, using the .rollup() adapter as-is typically works, or .vite() when consuming the plugin through Vite 8.
import { createUnplugin } from 'unplugin'
// Conceptual example: verify the actual adapter list against your unplugin version
const myPlugin = createUnplugin((options) => {
return {
name: 'my-universal-plugin',
transform: {
filter: {
id: /\.(ts|tsx)$/,
},
handler(code, id) {
return transformCode(code, id, options)
}
}
}
})
export const vitePlugin = myPlugin.vite
export const rollupPlugin = myPlugin.rollup
export const webpackPlugin = myPlugin.webpack
export const esbuildPlugin = myPlugin.esbuild
export const rspackPlugin = myPlugin.rspackIt's worth verifying in an actual project whether the .rollup() adapter loads cleanly under Rolldown. Thanks to the Rollup-compatible API, it works without issues in most cases.
Raw AST Transfer and Native MagicString
Raw AST Transfer
This feature passes the Oxc AST generated by Rust directly to JS plugins. The idea is to reduce serialization overhead for efficient AST-level transformations, but as of 2026, it is still experimental. The API may change, so it's safest to avoid it in production plugins for now.
Native MagicString
This replaces the JS version of MagicString with a Rust implementation to improve source map generation and code transformation performance. When you manipulate code in the transform hook, Rolldown uses this Rust implementation internally. In most cases users don't need to think about it directly, but it can be a useful clue when tracking down source map accuracy issues.
Summarized as a Decision Flow
When adding a new hook, keeping the following decision flow in mind is enough to avoid most performance pitfalls.
One easy-to-miss point here is placing excessive expectations on Rust-level integration. There is a known case where an attempt to integrate the React Compiler directly at the Rust level into Rolldown was reverted due to binary size increases. Rust-level integration offers performance gains but comes with tradeoffs like binary size and build complexity. The realistic approach is to first achieve sufficient performance with JS plugins and hook filters, then consider deeper integration only when a real bottleneck is confirmed.
Closing Thoughts
The core of writing Rolldown plugins ultimately comes down to being conscious of where control crosses into the JS runtime. The syntax is nearly identical to writing Rollup plugins, but the key difference is that every hook call crosses an FFI boundary.
Attaching a filter to hooks like transform that are called for every module so Rust can pre-screen them, and combining the \0 prefix with a load hook filter in the virtual module pattern — getting just these two things right goes a long way toward preventing JS plugins from becoming a bottleneck in the Rust pipeline.
If you want to go further, there are three directions. First, publish a library based on unplugin to distribute it simultaneously across Vite, Rolldown, Rollup, and Rspack. Second, reuse the same plugin as-is in tools like tsdown that wrap Rolldown as a library bundler. Third, if hook filters don't fully resolve a bottleneck, contribute that functionality as a Rust-level feature to the Rolldown core. The third path has a higher barrier to entry, but when a genuine performance bottleneck is concentrated in a specific hook, it is often the most honest answer.
References
- Announcing Rolldown 1.0 | VoidZero
- Vite 8.0 is out! | Vite Official Blog
- Vite 8 Beta: The Rolldown-powered Vite | Vite Official Blog
- Why Plugin Hook Filter | Rolldown Official Docs
- PluginContext Interface | Rolldown Reference
- GitHub — rolldown/rolldown
- Rolldown Pulls Rust React Compiler Integration After Binary Size Increase | Socket
- renderChunk hook code filter PR #4351 | rolldown/rolldown
- unplugin GitHub
- tsdown Official Site