Skip to content

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

Description

@benjamineckstein

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:

  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.

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

    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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions