Repository navigation
[plugin-rsc] Dev server retains every request: the reference require cache is keyed by a per-render cache tag and never evicts #1476
Description
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; doneSampling 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 CPUsUsed Package Manager
pnpm
Logs
No errors are logged; the symptom is heap growth.
Validations
- Follow our Code of Conduct
- Read the Contributing Guidelines.
- Read the docs.
- Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.
- Make sure this is a Vite issue and not a framework-specific issue.
🤖 The investigation, this report and the linked fix were prepared with Claude Code (AI) on my behalf.
Activity