Verified against: @tailwindcss/vite 4.3.3, vite 8.3.0 (bundling with rolldown 1.2.9),
astro 7.3.3, Node 24.18.0
Summary
The build plugin caches a Root per (environment, css id) for the lifetime of the plugin
instance. The Root's compiler is created inside the transform hook and its onDependency
option closes over the addWatchFile callback that the hook was called with, which is
(file) => this.addWatchFile(file), i.e. a closure over the build's PluginContext.
Nothing releases that closure when the bundle is done, so the plugin context of that build
stays reachable for as long as the plugin instance lives. On Vite 8 the bundler is Rolldown,
so the context is a thin JS handle onto a native bundler arena: holding it keeps the finished
bundle alive in Rust memory rather than on the JS heap. In a one-shot vite build this is
invisible because the process exits right after. In any framework that keeps doing work inside
the same Vite build session, it is a multi-GB retainer.
Astro's static build is the clearest case: builder.buildApp() runs the SSR/prerender build
and then generatePages() inside the same call, so the prerender PluginContext is still
strongly reachable through rootsByEnv while every page is rendered.
Retainer chain
module-scope (per plugin instance)
rootsByEnv: DefaultMap<string, Map<string, Root>> // never cleared
-> Map<cssId, Root>
-> Root.compiler // set in Root.generate()
-> compile(..., { onDependency }) // @tailwindcss/node holds the option
-> onDependency closure
-> addWatchFile === (file) => this.addWatchFile(file)
-> PluginContext of the prerender build
-> native Rolldown bundler arena for that build
Because the retained bytes sit in the native arena and not in V8, heap-after-GC does not move
at all. The cost is only visible in RSS. That is worth stating up front, since a heap-only
investigation concludes there is no leak.
The relevant code in packages/@tailwindcss-vite/src/index.ts:
let rootsByEnv = new DefaultMap<string, Map<string, Root>>((env: string) => new Map())
// ...
{
name: '@tailwindcss/vite:generate:build',
apply: 'build',
enforce: 'pre',
transform: {
filter: { /* ... */ },
async handler(src, id) {
// ...
let roots = rootsByEnv.get(this.environment?.name ?? 'default')
let root = roots.get(id)
if (!root) {
root ??= createRoot(this.environment ?? null, id)
roots.set(id, root)
}
let result = await root.generate(src, (file) => this.addWatchFile(file), I)
// ...
},
},
}
and in Root.generate:
this.compiler = await compile(content, {
from: this.enableSourceMaps ? this.id : undefined,
base: inputBase,
shouldRewriteUrls: true,
onDependency: (path) => {
addWatchFile(path) // <- captured PluginContext
addBuildDependenciesPromises.push(this.addBuildDependency(path))
},
customCssResolver: this.customCssResolver,
customJsResolver: this.customJsResolver,
})
Note that addWatchFile is captured from the first transform call only. Later calls pass a
fresh callback that is used for the else branch, but the compiler keeps the original one
forever.
Impact (measured)
Astro 7.3.3 static site, 3,864 pages, one @tailwindcss/vite root CSS file, Node 24.18.0,
macOS 26.6 / M-series, 18 cores, astro build with build.concurrency: 4.
|
wall time |
peak RSS |
peak heap after forced GC |
| as shipped |
224 s |
16,404 MB |
1,166 MB |
| context detached |
172 s |
9,782 MB |
1,104 MB |
Same pair with a global.gc() probe running every 3 s (so the numbers are directly
comparable to each other, not to the row above):
|
wall time |
peak RSS |
peak heap after forced GC |
| as shipped |
180 s |
21,332 MB |
1,166 MB |
| context detached |
174 s |
7,593 MB |
1,104 MB |
Two things are worth calling out, because they make the bug easy to miss:
- The live heap does not grow. Heap-after-full-GC sits flat at ~905 MB through the whole
of route generation in both variants. Looking only at heap-after-GC, the retention is
invisible.
- The cost is a transient RSS explosion at the build/route-generation boundary. In the
as-shipped run, RSS goes from 6.1 GB at t=54 s to 21.0 GB at t=68 s and then decays back to
7.3 GB. With the context detached it never exceeds 7.5 GB. The retained context is what
stops the bundler arena being released before the next phase starts allocating, so the two
working sets overlap instead of succeeding one another.
Output is unaffected: identical emitted file set (6,309 files), all 2,445 non-HTML files
byte-identical, and 0 of 3,864 HTML pages differ once per-render random element ids are
normalised. The emitted CSS is byte-identical.
Linux CI for the same project (GitHub Actions, 16 GB runner, Node 24) reports peak RSS of
8,192 / 8,293 / 8,357 / 8,407 MB across four consecutive as-shipped runs. A 7 GB runner OOM'd
on this build, which is why the larger runner is in use at all.
Repro
No Astro needed. Wrap the build plugin's transform so it records a WeakRef to the
PluginContext Tailwind is called with, run a build, force a GC, then ask whether the context
is still alive.
// probe.mjs -> node --expose-gc probe.mjs and node --expose-gc probe.mjs --fix
import { build } from 'vite'
import tailwindcss from '@tailwindcss/vite'
const APPLY_FIX = process.argv.includes('--fix')
const NAME = '@tailwindcss/vite:generate:build'
let ctxRef = null
let attachmentRef = null
function instrument(plugins) {
return plugins.map((plugin) => {
if (plugin.name !== NAME) return plugin
const original = plugin.transform.handler
return {
...plugin,
transform: {
...plugin.transform,
handler(...args) {
if (!ctxRef) {
const hangingOffTheContext = { marker: 'reachable-through-plugin-context' }
this.probeAttachment = hangingOffTheContext
ctxRef = new WeakRef(this)
attachmentRef = new WeakRef(hangingOffTheContext)
}
const self = APPLY_FIX ? { environment: this.environment, addWatchFile() {} } : this
return original.apply(self, args)
},
},
}
})
}
await build({
root: import.meta.dirname,
logLevel: 'error',
configFile: false,
build: { write: false, lib: { entry: 'src/main.js', formats: ['es'], fileName: 'out' } },
plugins: [instrument(tailwindcss())],
})
for (let i = 0; i < 5; i++) {
global.gc()
await new Promise((resolve) => setImmediate(resolve))
}
console.log(
`mode=${APPLY_FIX ? 'fixed' : 'as-shipped'} ` +
`pluginContextAlive=${ctxRef?.deref() !== undefined} ` +
`attachmentAlive=${attachmentRef?.deref() !== undefined}`,
)
src/app.css is just @import "tailwindcss"; and src/main.js is import "./app.css".
Output on @tailwindcss/vite 4.3.3, vite 8.3.0, Node 24.18.0:
mode=as-shipped pluginContextAlive=true attachmentAlive=true
mode=fixed pluginContextAlive=false attachmentAlive=false
The build has fully completed and five major GCs have run, and the PluginContext plus
everything hanging off it is still strongly reachable.
For the real-world shape, use any Astro output: 'static' site with @tailwindcss/vite and a
few thousand pages, and sample process.memoryUsage().rss during the build. The spike sits
right at the handover from the bundler to route generation. Sample RSS, not heapUsed: the
retained bytes are in the native arena, so a heap-only probe shows nothing.
Suggested fixes (either is enough)
-
Do not capture the context. Store addWatchFile on the Root and have onDependency
read this.currentAddWatchFile at call time, so it always uses the callback of the transform
call that is actually running, instead of pinning the first one.
-
Clear the cache when the bundle is done. Add to the build plugin:
closeBundle() {
rootsByEnv.clear()
}
This keeps the caching benefit within a build and drops the retention afterwards.
(1) is the more complete fix: it also prevents a stale context being used for watch
registration when several environments share a Root.
Workaround in userland
Wrapping the build plugin's transform.handler so Tailwind sees a stub this with the real
environment and an inert addWatchFile removes the retention entirely and produces
byte-identical output. addWatchFile is a no-op outside watch mode anyway, so nothing is lost
for a one-shot build.
🤖 Generated with Claude Code
Verified against:
@tailwindcss/vite4.3.3,vite8.3.0 (bundling withrolldown1.2.9),astro7.3.3, Node 24.18.0Summary
The build plugin caches a
Rootper(environment, css id)for the lifetime of the plugininstance. The
Root's compiler is created inside thetransformhook and itsonDependencyoption closes over the
addWatchFilecallback that the hook was called with, which is(file) => this.addWatchFile(file), i.e. a closure over the build'sPluginContext.Nothing releases that closure when the bundle is done, so the plugin context of that build
stays reachable for as long as the plugin instance lives. On Vite 8 the bundler is Rolldown,
so the context is a thin JS handle onto a native bundler arena: holding it keeps the finished
bundle alive in Rust memory rather than on the JS heap. In a one-shot
vite buildthis isinvisible because the process exits right after. In any framework that keeps doing work inside
the same Vite build session, it is a multi-GB retainer.
Astro's static build is the clearest case:
builder.buildApp()runs the SSR/prerender buildand then
generatePages()inside the same call, so the prerenderPluginContextis stillstrongly reachable through
rootsByEnvwhile every page is rendered.Retainer chain
Because the retained bytes sit in the native arena and not in V8, heap-after-GC does not move
at all. The cost is only visible in RSS. That is worth stating up front, since a heap-only
investigation concludes there is no leak.
The relevant code in
packages/@tailwindcss-vite/src/index.ts:and in
Root.generate:Note that
addWatchFileis captured from the first transform call only. Later calls pass afresh callback that is used for the
elsebranch, but the compiler keeps the original oneforever.
Impact (measured)
Astro 7.3.3 static site, 3,864 pages, one
@tailwindcss/viteroot CSS file, Node 24.18.0,macOS 26.6 / M-series, 18 cores,
astro buildwithbuild.concurrency: 4.Same pair with a
global.gc()probe running every 3 s (so the numbers are directlycomparable to each other, not to the row above):
Two things are worth calling out, because they make the bug easy to miss:
of route generation in both variants. Looking only at heap-after-GC, the retention is
invisible.
as-shipped run, RSS goes from 6.1 GB at t=54 s to 21.0 GB at t=68 s and then decays back to
7.3 GB. With the context detached it never exceeds 7.5 GB. The retained context is what
stops the bundler arena being released before the next phase starts allocating, so the two
working sets overlap instead of succeeding one another.
Output is unaffected: identical emitted file set (6,309 files), all 2,445 non-HTML files
byte-identical, and 0 of 3,864 HTML pages differ once per-render random element ids are
normalised. The emitted CSS is byte-identical.
Linux CI for the same project (GitHub Actions, 16 GB runner, Node 24) reports peak RSS of
8,192 / 8,293 / 8,357 / 8,407 MB across four consecutive as-shipped runs. A 7 GB runner OOM'd
on this build, which is why the larger runner is in use at all.
Repro
No Astro needed. Wrap the build plugin's transform so it records a
WeakRefto thePluginContextTailwind is called with, run a build, force a GC, then ask whether the contextis still alive.
src/app.cssis just@import "tailwindcss";andsrc/main.jsisimport "./app.css".Output on
@tailwindcss/vite4.3.3,vite8.3.0, Node 24.18.0:The build has fully completed and five major GCs have run, and the
PluginContextpluseverything hanging off it is still strongly reachable.
For the real-world shape, use any Astro
output: 'static'site with@tailwindcss/viteand afew thousand pages, and sample
process.memoryUsage().rssduring the build. The spike sitsright at the handover from the bundler to route generation. Sample RSS, not
heapUsed: theretained bytes are in the native arena, so a heap-only probe shows nothing.
Suggested fixes (either is enough)
Do not capture the context. Store
addWatchFileon theRootand haveonDependencyread
this.currentAddWatchFileat call time, so it always uses the callback of the transformcall that is actually running, instead of pinning the first one.
Clear the cache when the bundle is done. Add to the build plugin:
This keeps the caching benefit within a build and drops the retention afterwards.
(1) is the more complete fix: it also prevents a stale context being used for watch
registration when several environments share a
Root.Workaround in userland
Wrapping the build plugin's
transform.handlerso Tailwind sees a stubthiswith the realenvironmentand an inertaddWatchFileremoves the retention entirely and producesbyte-identical output.
addWatchFileis a no-op outside watch mode anyway, so nothing is lostfor a one-shot build.
🤖 Generated with Claude Code