Skip to content

fix: evict stale defer entries in dev server to bound registry memory#159

Merged
uhyo merged 3 commits into
masterfrom
claude/issue-144-kitpof
Jul 21, 2026
Merged

fix: evict stale defer entries in dev server to bound registry memory#159
uhyo merged 3 commits into
masterfrom
claude/issue-144-kitpof

Conversation

@uhyo

@uhyo uhyo commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Closes #144

Problem

deferRegistry in the RSC environment is a module-level singleton. In dev, every HTML render calls defer() with fresh crypto.randomUUID() IDs and registers new entries that are never evicted — pending entries retain the React element, and loaded entries retain the entire rendered payload string via drainPromise. Over a long dev session, memory grows without bound.

Fix

The dev-only request handlers (serveHTML / serveRSC) call a new evictStale() method at the start of each request. The eviction policy distinguishes the two entry lifecycles:

  • Settled entries (streaming/ready/error — already fetched, retaining the rendered payload string, the bulk of the leak) are dropped once not loaded within a 5-minute TTL. This is safe because the client caches fetched payloads per module ID (moduleToStreamMap), so even a remounted DeferredComponent never re-fetches from the server.
  • Pending entries (not yet fetched, retaining only the React element) are never evicted by time — deferred content may be fetched arbitrarily late, e.g. content inside an accordion UI that is rarely opened. Instead they are capped at 1000, evicting the oldest first, so memory stays bounded across long HMR-heavy sessions.

The cap is enforced in evictStale() (called only from dev handlers), not in register(), so production builds that register many defers before loadAll() are unaffected. Evicting an entry does not cancel an in-flight render: responses already holding the entry's stream or drain promise are unaffected.

Refactoring

DeferRegistry is extracted into its own module (deferRegistry.ts) with the RSC renderer injected via the constructor. The RSC runtime cannot be imported outside a react-server environment, so this is what makes the registry unit-testable. Internal modules import the registry class, types, and eviction options directly from deferRegistry.ts; the public API (defer, DeferOptions) stays in defer.tsx and is unchanged.

Tests

  • 11 unit tests covering eviction (pending entries survive any idle time, settled TTL expiry, access refresh, pending cap with oldest-first order, settled entries not counting toward the cap, eviction safety for in-flight loads) plus previously-untested loadAll behavior including nested defers.
  • All unit tests, typecheck, lint, all 26 dev-server E2E tests (including ssr-defer), and all 29 build E2E tests pass.

Remaining trade-off

A pending entry can only be lost while its page is still open if 1000+ newer defer() registrations accumulate (e.g. leaving a tab open overnight while heavily editing with HMR). In that rare case the fetch 404s and a page refresh recovers. The policy constants live in devDeferEvictionOptions and are easy to tune.

🤖 Generated with Claude Code

https://claude.ai/code/session_0114YtjwyWWEE4wE1d5dGgDu

claude added 3 commits July 20, 2026 15:13
The RSC-side deferRegistry is a module-level singleton; in dev every
render registers fresh entries (with new random IDs) that were never
evicted, so pending elements and drained payload strings accumulated
for the lifetime of the dev session.

Entries now record a last-accessed timestamp (refreshed on load), and
the dev request handlers (serveHTML / serveRSC) drop entries not
touched within a 5-minute TTL. The TTL leaves a generous window for
lazily-rendered DeferredComponents to still fetch their payload, and
evicting an entry does not affect responses already holding its stream.
Production builds are unchanged: eviction only runs from dev handlers.

DeferRegistry is extracted to its own module with the RSC renderer
injected, so the registry (including eviction and nested-defer
loadAll behavior) is now covered by unit tests.

Closes #144

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114YtjwyWWEE4wE1d5dGgDu
Deferred content may be fetched arbitrarily late — e.g. content inside
an accordion UI that is rarely opened — so evicting pending entries by
time would 404 the fetch on long-idle pages.

Pending entries (which retain only the React element) are now never
evicted by time; instead they are capped at 1000, evicting the oldest
first. The TTL applies only to settled entries (which retain the
rendered payload string, the bulk of the leak); those are safe to
evict because the client caches fetched payloads per module ID and
never legitimately re-fetches them.

The cap is enforced in evictStale (dev handlers only), not register,
so builds registering many defers before loadAll are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114YtjwyWWEE4wE1d5dGgDu
Internal modules now import DeferRegistry types and the eviction
options from deferRegistry.ts directly; defer.tsx no longer re-exports
them. The public API (defer, DeferOptions) is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114YtjwyWWEE4wE1d5dGgDu
@uhyo
uhyo merged commit 2df7710 into master Jul 21, 2026
2 checks passed
@uhyo
uhyo deleted the claude/issue-144-kitpof branch July 21, 2026 12:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dev server: deferRegistry grows unboundedly across requests

2 participants