Skip to content

Fix single-entry cache thrashing in selectSingleTokenByAddressAndChainId (O(n) scan per call) #31490

Description

@MajorLift

Performance audit finding · Severity: High · Effort: Medium · Fix risk: Simple · Test safety net: Partial
Owner: @MetaMask/metamask-assets (suggested)
File: app/selectors/tokensController.ts:174

What is this about?

selectSingleTokenByAddressAndChainId is a parameterized createSelector with reselect's default single-entry cache. Every call with a different (tokenAddress, chainId) pair busts the cache of the previous call, and the result function runs an O(n) scan (Object.values(allTokens[chainId] ?? {}).flat() then .find()) on every miss. In a list rendering N rows that each resolve a token, the "memoized" selector recomputes N times per render cycle, forever.

Why it matters

Cost scales with token count × visible rows. For a power-user data profile (~90+ assets) this is a full token-map flatten + linear scan per row per render, on hot asset paths.

Scenario

N/A — see Technical Details.

Design

N/A — internal performance change; no UI/design impact.

Technical Details

Evidence

app/selectors/tokensController.ts:174

export const selectSingleTokenByAddressAndChainId = createSelector(
  getTokensControllerAllTokens,
  (_state: RootState, tokenAddress: Hex) => tokenAddress,
  (_state: RootState, _tokenAddress: Hex, chainId: Hex) => chainId,
  (allTokens, tokenAddress, chainId) => {
    const chainTokens = Object.values(allTokens[chainId] ?? {}).flat();
    return chainTokens.find(
      (token) => token.address.toLowerCase() === tokenAddress.toLowerCase(),
    );
  },
);

Fix

Preferred: a lookup-map selector — memoize one chainId → addressLowercase → Token index that recomputes only when allTokens changes, and have consumers key into it (O(1) per lookup, no per-arg cache to bust). Alternative: a makeSelectSingleTokenByAddressAndChainId() factory instantiated per call site with useMemo, so each call site owns its own cache slot.

Threat Modeling Framework

N/A — performance-only change; behavior is preserved, no new data flow / trust boundary / attack surface.

Acceptance Criteria

  • Index selector returns the same reference across two dispatches with unchanged token data (assert with toBe).
  • selector.recomputations() (or console.count in the result fn) drops from N-per-render to ~1-per-data-change while scrolling a token list.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    Sev2Sev2-normalAn issue that may lead to users misunderstanding some limited risks they are takingarea-performanceIssues relating to slowness of app, cpu usage, and/or blank screens.size-Sta-triagedteam-assets

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    To be fixed

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions