Skip to content

[plugin-rsc] Dev server retains every request: the reference require cache is keyed by a per-render cache tag and never evicts #1476

Description

@fdiazsmith

Related plugins

Describe the bug

In dev, the heap of a @vitejs/plugin-rsc server grows with every request, and it never comes back. The cause is the memoized reference require. Each render builds its client manifest with a fresh random $$cache= tag (createReferenceCacheTag in createClientManifest), so every render's reference ids are new keys. The memoize behind __vite_rsc_client_require__ (src/core/ssr.ts, and the same pattern in core/browser.ts and core/rsc.ts) has no eviction.

That alone would be a small leak. It's a large one because of what each entry holds: a promise created inside the request that first loaded it. That promise carries the request's AsyncLocalStorage store (kResourceStore), and the store is React's RequestInstance. So every render's whole React request stays reachable, including its dev debug data (captured stacks, ReactPromises).

It only happens in dev: builds use no cache tag.

What holds it

Heap snapshots taken 600 requests apart: +82 MB. The biggest growth by constructor is 410k CallSiteInfo, 311k Object, 70k ReactPromise, 42k Error, and 596 RequestInstance, one per request. The shortest strong retainer path from the GC roots to a RequestInstance:

global
  .__vite_rsc_client_require__            (closure)
  .context.cache                          (the memoize Map)
  [entry]                                 Promise
  .<symbol kResourceStore>                RequestInstance

After ~900 requests the Map held 898 entries, one per render, keyed like:

/@id/__x00__virtual:vite-rsc/remove-duplicate-server-css$$cache=3pwz7y36zcw
/@id/__x00__virtual:vite-rsc/remove-duplicate-server-css$$cache=d1hkdhwtzys
/@id/__x00__virtual:vite-rsc/remove-duplicate-server-css$$cache=62kl6tr8c2s
...

RemoveDuplicateServerCss is the client component the plugin injects in serve mode, so every app hits this, whether or not it renders client components of its own.

Why the tag can't simply go

The tag is intentional. It is // cache bust memoized require promise during dev: after an edit, a render has to call import(id) again rather than reuse a promise that resolved to the old module. The memoize is needed too: React's client runtime calls __webpack_require__(id) from preloadModule, stamps status on the returned promise, and later calls it again from requireModule, throwing unless it gets the same, already-fulfilled promise back. So entries for a render's tag have to live at least as long as that render's decode. Nothing needs them after that.

Suggested fix

Group the memoized entries by cache tag, and keep only the most recently used N tags (untagged ids stay memoized for good, as in builds today). A render's tag is touched whenever it requires a module, so it is only evicted if N other renders started between its preload and its require. With N = 64, retention is capped at 64 requests rather than every request since the server started.

I have this working, and I'm happy to open a PR. Re-measured the same way with the patch:

route unpatched patched
/ 52 → 94 MB 52 → 51 MB, flat
async data page 54 → 127 MB 48 → 45 MB, flat

Edits still show up without a restart: I changed a client component and a server component while the patched dev server ran, and each change appeared in the next SSR response (Vite logged (ssr) page reload src/client.tsx and (rsc) hmr update). The unit suite passes, with 6 new tests covering stability within a tag, reloading under a new tag, untagged ids, LRU eviction, a tag in use surviving newer ones, and a bounded size after 4x the limit.

Reproduction

https://github.com/vitejs/vite-plugin-react/tree/3b14e48/packages/plugin-rsc/examples/starter

Steps to reproduce

The examples/starter in this repo, unchanged (@vitejs/plugin-rsc 0.5.35, vite 8.3.1, react 19.3.0, Node 22.22.3):

cd packages/plugin-rsc/examples/starter
NODE_OPTIONS=--expose-gc pnpm dev
# in another shell: ~2 requests/s for a few minutes
for i in $(seq 1 600); do curl -s -o /dev/null http://localhost:5173/; sleep 0.5; done

Sampling process.memoryUsage().heapUsed right after a forced gc() every 30s, in the Vite process:

route post-GC heap over 5 min, 2 req/s retained per request
/ (starter as-is) 52 → 94 MB, a steady climb ~71 KiB
an async server component page (37 awaited fetches per render) 54 → 127 MB, a steady climb ~125 KiB

The growth is linear from the first sample, not a warm-up step.

System Info

@vitejs/plugin-rsc 0.5.35 (same code on main at 3b14e48)
vite 8.3.1
react, react-dom, vendored react-server-dom-webpack 19.3.0
Node 22.22.3, Linux (Docker node:22 image), 2 CPUs

Used Package Manager

pnpm

Logs

No errors are logged; the symptom is heap growth.

Validations


🤖 The investigation, this report and the linked fix were prepared with Claude Code (AI) on my behalf.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions