#20501·Tailwind CSS

@tailwindcss/vite: cached Root.compiler pins the build's plugin context, keeping the finished bundle alive (multi-GB RSS in Astro SSG)

Author: benjaminecksteinCreated Sep 20, 2026Updated Sep 25, 2026

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:

typescript
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:

typescript
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:

  1. 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.
  2. 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.

javascript
// 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)

  1. 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.

  2. Clear the cache when the bundle is done. Add to the build plugin:

    typescript
    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

Source: tailwindlabs/tailwindcss