Skip to content

fix: memoize version collection fields to bound flattenedFieldsCache growth#17356

Open
alevochko wants to merge 1 commit into
payloadcms:mainfrom
alevochko:fix/version-collection-fields-cache-leak
Open

fix: memoize version collection fields to bound flattenedFieldsCache growth#17356
alevochko wants to merge 1 commit into
payloadcms:mainfrom
alevochko:fix/version-collection-fields-cache-leak

Conversation

@alevochko

Copy link
Copy Markdown

fix(payload): memoize version collection fields to bound flattenedFieldsCache growth

What / Why

buildVersionCollectionFields() is called on every operation against a
versioned/drafts-enabled collection (find, findVersions, findVersionByID,
countVersions, update, …). Each call allocates a brand-new fields array
(array literal + collection.fields.filter(...)).

Those arrays are then flattened by flattenAllFields(), whose module-level
flattenedFieldsCache (a plain Map) is keyed by array identity and always
runs flattenedFieldsCache.set(fields, result). Because the key is a fresh array
each call, the cache never hits and grows by (at least) one strong-referenced
entry per request — an unbounded memory leak that scales with request count,
not with the number of collections.

Under public read traffic this is a slow, steady heap climb on every pod.

Root cause (confirmed from a production heap-snapshot diff)

Two snapshots ~19h apart of the same process (identical traffic) showed a
monotonic heap climb driven by ~218k new Object + ~103k new Array nodes,
96–97% retained via a single Map (flattenedFieldsCache) whose table held
~36,000 entries (1.8 MB). The entries were Map<fieldsArray, flattenedFields>
pairs, and the values were exactly the fields produced here — parent,
version (group), publishedLocale, latest, createdAt, updatedAt — i.e.
the version collection schema, re-created and re-cached on every read.

The schema is derived purely from the sanitized (stable) collection config,
so it is identical for every call — it should be built once and reused.

The fix

Memoize buildVersionCollectionFields per collection in a WeakMap keyed by the
sanitized collection config, caching the flattened and unflattened variants
separately (the flatten flag changes the version group shape). This returns a
referentially stable array, so:

  • redundant per-request rebuilding is eliminated, and
  • flattenedFieldsCache now hits (stable key) instead of growing — the leak is
    gone, and the cache does what it was designed to do.

WeakMap keeps it safe: if a collection config is ever discarded, its entry is
reclaimed.

This touches only buildVersionCollectionFields, so all seven call sites benefit
without changes.

Tests

Added packages/payload/src/versions/buildCollectionFields.spec.ts:

  • returns a referentially stable array across calls for the same collection;
  • caches flattened vs unflattened variants separately (each stable);
  • memoizes per collection (distinct references for distinct collections);
  • still produces the expected version schema (parent/version/createdAt/
    updatedAt/latest; the version group drops the base id, keeps others).

Affected versions

Present on main (4.0.0-beta) and all 3.x releases checked (3.79 → 3.86.0) and
4.0.0-canary — the code is identical across them.

@alevochko alevochko changed the title fix(payload): memoize version collection fields to bound flattenedFieldsCache growth fix: memoize version collection fields to bound flattenedFieldsCache growth Jul 16, 2026
@alevochko
alevochko force-pushed the fix/version-collection-fields-cache-leak branch from c161fe3 to 4aeb298 Compare July 16, 2026 09:10
@alevochko
alevochko marked this pull request as ready for review July 16, 2026 09: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.

1 participant