Skip to content

feat: add maxSize option to weakMapMemoize to bound cache growth (#635)#761

Open
veksa wants to merge 4 commits into
reduxjs:masterfrom
veksa:feat/weakmap-memoize-max-size
Open

feat: add maxSize option to weakMapMemoize to bound cache growth (#635)#761
veksa wants to merge 4 commits into
reduxjs:masterfrom
veksa:feat/weakmap-memoize-max-size

Conversation

@veksa

@veksa veksa commented Jul 19, 2026

Copy link
Copy Markdown

feat: add maxSize option to weakMapMemoize to bound cache growth

Fixes #635

Problem

weakMapMemoize builds a tree of cache nodes keyed by the identity of each
argument. Object/function arguments are stored in WeakMaps and are cleaned up
by the garbage collector once the argument becomes unreachable. Primitive
arguments, however, are stored in regular strong Maps.

When a selector is called with a long-lived object as the first argument (for
example the Redux state, or a stable value derived by a sub-selector) followed
by ever-changing primitives, every result that was ever computed is retained for
the entire lifetime of that object. Nothing is evicted, so the strong Maps
grow without bound — a de-facto memory leak.

const selector = weakMapMemoize(
  (array: number[], from: number, to: number) => array.slice(from, to)
)

const state = Array.from({ length: 10_000 }, (_, i) => i)
const first = selector(state, 2000, 2500)

for (let i = 0; i < 10_000; i++) selector(state, i, i + 100)

selector(state, 2000, 2500) === first // true — still cached, forever

A realistic trigger is an endlessly scrolling list backed by a selector such as
(state, from, to) => state.items.slice(from, to): as from/to change with
every scroll event, every intermediate slice stays in memory for as long as the
state object is alive.

Change

Add an opt-in maxSize option to WeakMapMemoizeOptions:

weakMapMemoize(fn, { maxSize: 10 })
  • Caps the number of cached results using a least-recently-used (LRU) policy.
  • When the limit is exceeded, the least-recently-used result is evicted and any
    now-empty ancestor Map branches are pruned, so the strong primitive Maps
    stay bounded and evicted results become eligible for garbage collection.
  • Object (WeakMap) branches continue to rely on normal garbage collection and
    are not pruned.

Design notes

  • Backwards compatible and zero-cost when unused. Without maxSize the cache
    is unbounded, exactly as before. The LRU bookkeeping and the extra node
    back-references (parent/map/key) are only maintained when maxSize is
    set, so the hot path for existing users is unchanged.
  • The default behavior is intentionally left unbounded. Making weakMapMemoize
    bounded by default is a separate, breaking decision and is out of scope here.
  • maxSize is validated to be a positive integer.
  • clearCache() resets the LRU state.

Bounding a createSelector

createSelector memoizes on two levels: argsMemoize caches on the raw
selector arguments, and memoize caches the result function on the extracted
input values. Both hold results keyed by primitive arguments, so both must be
bounded — otherwise argsMemoize short-circuits and the leak remains:

const selectSlice = createSelector(
  [(state: RootState) => state.items, (_: RootState, from: number) => from],
  (items, from) => items.slice(from),
  {
    memoize: weakMapMemoize,
    memoizeOptions: { maxSize: 10 },
    argsMemoize: weakMapMemoize,
    argsMemoizeOptions: { maxSize: 10 }
  }
)

Tests

test/weakMapMemoize.memory.spec.ts adds a toBeGarbageCollected matcher
(garbage collection is exposed via node --expose-gc, already used by the
test script) and covers:

  • The unbounded behavior on the current implementation: an old result stays
    cached after many distinct calls and is not collected while the state object
    remains reachable.
  • maxSize validation.
  • Caching within the configured size, LRU eviction once it is exceeded, and a
    recently-used result surviving while a staler one is evicted.
  • Eviction of object-keyed results (the WeakMap branch), clearCache()
    resetting the bounded cache, and coexistence with resultEqualityCheck.
  • Bounding a full createSelector via maxSize on both memoizeOptions and
    argsMemoizeOptions.
  • With a small maxSize and many distinct primitive combinations, an early
    result is garbage collected while the state object and the selector stay
    alive.

@netlify

netlify Bot commented Jul 19, 2026

Copy link
Copy Markdown

Deploy Preview for reselect-docs canceled.

Name Link
🔨 Latest commit b227fe0
🔍 Latest deploy log https://app.netlify.com/projects/reselect-docs/deploys/6a5e2363fecc34000800eb19

@codesandbox-ci

codesandbox-ci Bot commented Jul 19, 2026

Copy link
Copy Markdown

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

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.

Investigate potential memory leak issues with weakmapMemoize

1 participant