internal-docs/lazy-compilation/design.md
The implementation — data lifecycle, module-ID handling, end-to-end flow, and lessons learned: see implementation.md.
import('./module') just works; the plugin rewrites dynamic imports and unwraps proxy exports automaticallyrolldown:exports contract - proxy modules export this named export; the plugin's transform_ast chains .then(__unwrap_lazy_compilation_entry) onto every dynamic import in non-proxy modulesimport() become new lazy boundaries/@vite/lazy returns the compiled code as a single JS string; the browser loads it as an ES module (only inline sourcemaps survive — see implementation.md)/@vite/lazy?id= param and the fetched template's import($MODULE_ID)await import() catchably (#9981)executed_modules set used to prune the lazy patchLazy compilation is a development optimization that defers compilation of dynamically imported modules until they are actually requested at runtime.
import() is compiled just-in-time when the browser executes itimport('./foo') should just workLazy compilation is opt-in, nested inside dev mode:
export default {
experimental: {
devMode: { lazy: true },
},
};
devMode alone enables the dev/HMR machinery (HmrPlugin); lazy: true additionally prepends LazyCompilationPlugin before user plugins (crates/rolldown/src/utils/apply_inner_plugins.rs).context() exposes the shared lazy_entries / fetched_entries sets as a LazyCompilationContext, which is handed to the DevEngine so it can call mark_as_fetched before each lazy compile.lazy: true by default.import()) - static imports are always compiled/@vite/lazy request itself emits no HMR update — but once fetched, the lazy module is an ordinary watched graph module, and later edits to it flow through the normal per-client HMR pipeline (see implementation.md "Editing a fetched lazy module")resolve_id proxies every dynamic import, with no extension or module-type filter, so the real target is not loaded until the first /@vite/lazy request. Everything in the compiled unit must render as an ECMAScript AST:
/lazy request (HTTP 500, catchable rejection at the consumer's await import())load hook (e.g. the dev server's Vite-style asset plugin); emitted bytes are delivered via onAdditionalAssets (#9815)new URL(...) references count as staticWhen a lazy module is requested:
executed_modules)import() within the lazy module) are not compiled - they become their own lazy boundariesEntry
├── sync-dep-1 (compiled immediately)
├── sync-dep-2 (compiled immediately)
└── import('./lazy-a') ← lazy boundary
├── sync-dep-3 (compiled when lazy-a is requested)
├── sync-dep-4 (compiled when lazy-a is requested)
└── import('./lazy-b') ← another lazy boundary (NOT compiled yet)
Inside a rendered lazy chunk (or HMR patch), a nested import() of another lazy proxy is rewritten by the HMR finalizer to fetch /@vite/lazy?... and then read the proxy's registered exports via loadExports(stableProxyId) — partial bundles have no separately bundled proxy chunk, so the proxy's top-level export would otherwise be lost (see implementation.md "Lazy chunk rendering").
Users should not need to change their code. import('./module') just works.
rolldown:exports ContractProxy modules export a special named export 'rolldown:exports' — a promise that resolves to the real module's exports (and rejects if the real module throws during initialization, which is what makes init errors catchable at the consumer's await import(), #9981).
Rolldown's transform_ast hook automatically wraps dynamic imports with an unwrapping helper:
// User code (unchanged)
const mod = await import('./lazy.js');
// Transformed by lazy compilation plugin
const mod = await import('./lazy.js').then(__unwrap_lazy_compilation_entry);
The helper is injected (after any directive prologues) into each module where at least one dynamic import was wrapped:
function __unwrap_lazy_compilation_entry(m) {
var e = m['rolldown:exports'];
return e ? e : m;
}
This is safe for ALL dynamic imports: lazy proxies return the promise, non-lazy modules pass through unchanged
Proxy modules themselves (ids containing ?rolldown-lazy=1) are exempt: transform_ast skips them, so the stub's import('/@vite/lazy?...') and the fetched template's import($MODULE_ID) are never wrapped
A proxy module has two states that determine what content the LazyCompilationPlugin returns:
Returns the stub template (proxy-module-template.js), which fetches via the /@vite/lazy endpoint:
const lazyExports = (async () => {
// Remove the cache of the current module from the runtime's module map.
// This module with key $STABLE_PROXY_MODULE_ID is swapped in the lazy loaded chunk again with the real module.
delete __rolldown_runtime__.modules[$STABLE_PROXY_MODULE_ID];
// Dev server will intercept this import and serve the actual module code.
// We send the proxy module ID (with ?rolldown-lazy=1) so the server can mark it as fetched.
await import(
/* @vite-ignore */ `/@vite/lazy?id=${encodeURIComponent($PROXY_MODULE_ID)}&clientId=${__rolldown_runtime__.clientId}`
);
// Loading the chunk re-registers this proxy id, exposing the real module's
// initializer as its own `rolldown:exports` promise. Await that promise (don't
// just hand back the namespace) so an error thrown while the real module
// initializes rejects `lazyExports` too, surfacing at the consumer's
// `await import(...)` (catchable) instead of escaping as an unhandled rejection.
return await __rolldown_runtime__.loadExports($STABLE_PROXY_MODULE_ID)['rolldown:exports'];
})();
export { lazyExports as 'rolldown:exports' };
Three steps: (1) evict the proxy's stale runtime registration so the lazy chunk can re-register the same stable proxy id with the real initializer; (2) fetch the lazy chunk; (3) resolve through the re-registered proxy's own 'rolldown:exports' promise — a two-level promise chain whose rejection semantics make init errors catchable.
Returns the fetched template (proxy-module-template-fetched.js), which imports the real module:
const lazyExports = (async () => {
await import($MODULE_ID);
return __rolldown_runtime__.loadExports($STABLE_MODULE_ID);
})();
export { lazyExports as 'rolldown:exports' };
The import result (namespace) is deliberately discarded: exports are read from the runtime registry by stable id, because chunk-level renaming can minify export names when a shared lazy module lands in a common chunk (#9132). $MODULE_ID is the absolute path (used for resolution only); $STABLE_MODULE_ID is the cwd-relative stable id.
The state transition is managed by LazyCompilationContext.mark_as_fetched().
The dev server handles /@vite/lazy?id=...&clientId=... requests:
?rolldown-lazy=1) and the client's UUIDDevEngine.compileEntry(moduleId, clientId) (TS) / DevEngine::compile_lazy_entry (Rust)executed_modules and marks the proxy as fetchedLazy entry module not found in cache (never resolved from the filesystem, so a malicious request cannot bundle arbitrary files; analogous to Vite's server.fs.strict, pinned by test, #9969)import($MODULE_ID) triggers compilation of the actual moduleonAdditionalAssets callback before the code is returned, so they are servable when the chunk executes (#9815)Content-Type: application/javascript) - the browser loads it as an ES module; compile failures answer HTTP 500/lazy request