Skip to content

Conversation

@sahar-fehri
Copy link
Contributor

@sahar-fehri sahar-fehri commented Dec 9, 2025

Description

Optimizes TokenListController storage to reduce write amplification by persisting tokensChainsCache via StorageService using per-chain files instead of a single monolithic state property.

Mobile: MetaMask/metamask-mobile#24019

Extension: MetaMask/metamask-extension#39250

Related: https://github.com/MetaMask/metamask-mobile/pull/22943/files

Related: https://github.com/MetaMask/decisions/pull/110

Related: #7192

Explanation

The tokensChainsCache (~5MB total, containing token lists for all chains) was persisted as part of the controller state. Every time a single chain's token list was updated (~100-500KB), the entire ~5MB cache was rewritten to disk, causing:

  • Startup performance issues (loading large state on app initialization)
  • Runtime performance degradation (frequent large writes during token fetches)
  • Impacts both extension

Solution

Per-Chain File Storage:
Each chain's cache is now stored in a separate file (e.g., tokensChainsCache:0x1, tokensChainsCache:0x89)
Only the updated chain (~100-500KB) is written on each token fetch, reducing write operations by ~90-95%
All chains are loaded in parallel at startup to maintain compatibility with TokenDetectionController
Key Changes:

  • Set tokensChainsCache metadata to persist: false to prevent framework-managed persistence
  • Added #loadCacheFromStorage() to load all per-chain files in parallel on initialization
  • Added #saveChainCacheToStorage(chainId) to persist only the specific chain that changed
  • Added #migrateStateToStorage() to automatically migrate existing cache data on first launch after upgrade
  • Updated clearingTokenListData() to remove all per-chain files

References

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

Introduces per-chain persistence for TokenListController via StorageService, reducing write amplification and refining startup behavior.

  • BREAKING: TokenListController now requires await controller.initialize(); tokensChainsCache metadata set to persist: false
  • Persist tokensChainsCache per chain using keys like tokensChainsCache:<chainId>; load all chains in parallel on init; skip redundant re-persistence
  • Debounced persistence of changed chains; robust error handling for save/load/clear paths
  • clearingTokenListData() is now async and removes all per-chain cache files before clearing state
  • Refactors internals (private fields, polling helpers) and exports DataCache; removes mutex-based locking
  • Adds comprehensive tests covering storage integration, debounce, migrations, error scenarios, and deprecated start/restart/stop
  • Adds @metamask/storage-service dependency and tsconfig references; updates CHANGELOG with breaking note and dependency bumps

Written by Cursor Bugbot for commit 009e026. This will update automatically on new commits. Configure here.

tokensChainsCache: {
includeInStateLogs: false,
persist: true,
persist: false, // Persisted separately via StorageService
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making this false to block disk writes

salimtb
salimtb previously approved these changes Dec 16, 2025
error,
);
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loaded storage data immediately re-persisted during initialization

Medium Severity

When #loadCacheFromStorage() loads data from storage and calls this.update(), it triggers #onCacheChanged via the subscription. At that moment, #previousTokensChainsCache is still {} (the initial value), so all loaded chains are detected as "new" and added to #changedChainsToPersist. Line 264 sets #previousTokensChainsCache only AFTER #loadCacheFromStorage() returns, but by then persistence is already scheduled. This causes all data just loaded from storage to be written back 500ms later, defeating the write amplification optimization that this PR aims to achieve.

Additional Locations (1)

Fix in Cursor Fix in Web

override destroy(): void {
super.destroy();
this.stopPolling();
this.#stopPolling();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debounce timer not cleared in destroy method

Medium Severity

The destroy() method stops polling but doesn't clear #persistDebounceTimer. If there's a pending debounced persistence operation when destroy() is called, the timer will still fire and attempt to call #persistChangedChains(), which uses this.messenger.call(...). This could cause errors or unexpected behavior after the controller is destroyed. The clearingTokenListData() method correctly clears this timer, suggesting the same cleanup should happen in destroy().

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error,
);
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loaded cache data immediately scheduled for re-persistence

Medium Severity

The comment at line 263 states "Initialize previous cache to prevent re-persisting loaded data" but this doesn't work as intended. When #loadCacheFromStorage() calls this.update() at line 397, it triggers the TokenListController:stateChange subscription synchronously. The callback #onCacheChanged executes while #previousTokensChainsCache is still {} (the initial value), causing all loaded chains to be detected as "new" and added to #changedChainsToPersist. Line 264 executes too late—after the debounced persistence is already scheduled. This causes unnecessary writes as all loaded cache data is immediately re-persisted to storage.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this.#persistDebounceTimer = undefined;
}
this.#changedChainsToPersist.clear();
this.#previousTokensChainsCache = {};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race between in-progress persistence and clearing operations

Medium Severity

If #persistChangedChains() is already executing (timer fired, async operation started) when clearingTokenListData() is called, clearing the debounce timer has no effect. The #persistChangedChains method copies chains to persist and then performs async setItem calls, while clearingTokenListData performs async removeItem calls. These operations can interleave, causing some chains to be re-saved after being removed. This results in data persisting in storage despite the user expecting it to be cleared.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

override destroy(): void {
super.destroy();
this.stopPolling();
this.#stopPolling();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debounce timer not cleared on destroy causes post-destruction errors

Medium Severity

The destroy() method does not clear #persistDebounceTimer. If there's a pending debounce when destroy() is called, the timer continues running and will fire after destruction, attempting to call this.messenger.call() on a potentially destroyed messenger. This inconsistency is evident since clearingTokenListData() properly clears this timer at lines 646-649, but destroy() does not. While errors are caught in #saveChainCacheToStorage, this creates a resource leak and may cause logged errors after destruction.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is fixed in destroy() it already has
if (this.#persistDebounceTimer) {
clearTimeout(this.#persistDebounceTimer);
this.#persistDebounceTimer = undefined;
}

error,
);
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loaded cache unnecessarily re-persisted during initialization

Low Severity

The comment at line 263 claims "Initialize previous cache to prevent re-persisting loaded data," but this doesn't work as intended. When #loadCacheFromStorage() calls this.update(), the subscription fires #onCacheChanged() while #previousTokensChainsCache is still empty. This marks all loaded chains as "changed" and schedules a debounce. Setting #previousTokensChainsCache at line 264 happens after #loadCacheFromStorage() returns, but the debounce is already scheduled. When the timer fires, all chains just loaded from storage are unnecessarily re-persisted to the same storage. The #changedChainsToPersist set needs to be cleared after loading completes.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error,
);
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loaded cache data unnecessarily re-persisted during initialization

Medium Severity

When initialize() is called and storage contains cached data, the loaded data is unnecessarily re-persisted to storage. The #previousTokensChainsCache is initialized to {} in the class field, and the state change subscription is set up in the constructor. When #loadCacheFromStorage() calls this.update(), it triggers #onCacheChanged, which compares against the empty #previousTokensChainsCache and detects all loaded chains as "new", scheduling them for persistence. The assignment at line 264 happens too late — the debounced persist has already been scheduled. This causes redundant storage writes on every initialization, undermining the performance optimization this PR aims to achieve.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sahar-fehri
Copy link
Contributor Author

@metamaskbot publish-preview

// Only remove loaded chains, not chains from initial state that need first persist
for (const chainId of Object.keys(loadedCache) as Hex[]) {
this.#changedChainsToPersist.delete(chainId);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initialization removes chains from persistence queue incorrectly

Medium Severity

The #loadCacheFromStorage method removes ALL chains in loadedCache from #changedChainsToPersist, but loadedCache contains all chains from storage, not just chains that were actually added to state. When a chain exists in both initial state and storage, the update() call preserves the initial state's data (correctly), but the chain is still removed from the persistence queue (incorrectly). This means initial state data that should be persisted to storage is silently dropped if the same chain also exists in storage with older data.

Fix in Cursor Fix in Web

@sahar-fehri
Copy link
Contributor Author

@metamaskbot publish-preview

@github-actions
Copy link
Contributor

Preview builds have been published. See these instructions for more information about preview builds.

Expand for full list of packages and versions.
{
  "@metamask-previews/account-tree-controller": "4.0.0-preview-821afcb8",
  "@metamask-previews/accounts-controller": "35.0.1-preview-821afcb8",
  "@metamask-previews/address-book-controller": "7.0.1-preview-821afcb8",
  "@metamask-previews/analytics-controller": "1.0.0-preview-821afcb8",
  "@metamask-previews/announcement-controller": "8.0.0-preview-821afcb8",
  "@metamask-previews/app-metadata-controller": "2.0.0-preview-821afcb8",
  "@metamask-previews/approval-controller": "8.0.0-preview-821afcb8",
  "@metamask-previews/assets-controller": "0.0.0-preview-821afcb8",
  "@metamask-previews/assets-controllers": "95.1.0-preview-821afcb8",
  "@metamask-previews/base-controller": "9.0.0-preview-821afcb8",
  "@metamask-previews/bridge-controller": "64.4.1-preview-821afcb8",
  "@metamask-previews/bridge-status-controller": "64.4.2-preview-821afcb8",
  "@metamask-previews/build-utils": "3.0.4-preview-821afcb8",
  "@metamask-previews/chain-agnostic-permission": "1.4.0-preview-821afcb8",
  "@metamask-previews/claims-controller": "0.4.1-preview-821afcb8",
  "@metamask-previews/composable-controller": "12.0.0-preview-821afcb8",
  "@metamask-previews/controller-utils": "11.18.0-preview-821afcb8",
  "@metamask-previews/core-backend": "5.0.0-preview-821afcb8",
  "@metamask-previews/delegation-controller": "2.0.0-preview-821afcb8",
  "@metamask-previews/earn-controller": "11.0.0-preview-821afcb8",
  "@metamask-previews/eip-5792-middleware": "2.1.0-preview-821afcb8",
  "@metamask-previews/eip-7702-internal-rpc-middleware": "0.1.0-preview-821afcb8",
  "@metamask-previews/eip1193-permission-middleware": "1.0.3-preview-821afcb8",
  "@metamask-previews/ens-controller": "19.0.1-preview-821afcb8",
  "@metamask-previews/error-reporting-service": "3.0.1-preview-821afcb8",
  "@metamask-previews/eth-block-tracker": "15.0.0-preview-821afcb8",
  "@metamask-previews/eth-json-rpc-middleware": "22.0.1-preview-821afcb8",
  "@metamask-previews/eth-json-rpc-provider": "6.0.0-preview-821afcb8",
  "@metamask-previews/foundryup": "1.0.1-preview-821afcb8",
  "@metamask-previews/gas-fee-controller": "26.0.1-preview-821afcb8",
  "@metamask-previews/gator-permissions-controller": "0.8.0-preview-821afcb8",
  "@metamask-previews/json-rpc-engine": "10.2.0-preview-821afcb8",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.8-preview-821afcb8",
  "@metamask-previews/keyring-controller": "25.0.0-preview-821afcb8",
  "@metamask-previews/logging-controller": "7.0.1-preview-821afcb8",
  "@metamask-previews/message-manager": "14.1.0-preview-821afcb8",
  "@metamask-previews/messenger": "0.3.0-preview-821afcb8",
  "@metamask-previews/multichain-account-service": "5.0.0-preview-821afcb8",
  "@metamask-previews/multichain-api-middleware": "1.2.5-preview-821afcb8",
  "@metamask-previews/multichain-network-controller": "3.0.1-preview-821afcb8",
  "@metamask-previews/multichain-transactions-controller": "7.0.0-preview-821afcb8",
  "@metamask-previews/name-controller": "9.0.0-preview-821afcb8",
  "@metamask-previews/network-controller": "28.0.0-preview-821afcb8",
  "@metamask-previews/network-enablement-controller": "4.0.0-preview-821afcb8",
  "@metamask-previews/notification-services-controller": "21.0.0-preview-821afcb8",
  "@metamask-previews/permission-controller": "12.2.0-preview-821afcb8",
  "@metamask-previews/permission-log-controller": "5.0.0-preview-821afcb8",
  "@metamask-previews/phishing-controller": "16.1.0-preview-821afcb8",
  "@metamask-previews/polling-controller": "16.0.1-preview-821afcb8",
  "@metamask-previews/preferences-controller": "22.0.0-preview-821afcb8",
  "@metamask-previews/profile-metrics-controller": "2.0.0-preview-821afcb8",
  "@metamask-previews/profile-sync-controller": "27.0.0-preview-821afcb8",
  "@metamask-previews/ramps-controller": "3.0.0-preview-821afcb8",
  "@metamask-previews/rate-limit-controller": "7.0.0-preview-821afcb8",
  "@metamask-previews/remote-feature-flag-controller": "4.0.0-preview-821afcb8",
  "@metamask-previews/sample-controllers": "4.0.1-preview-821afcb8",
  "@metamask-previews/seedless-onboarding-controller": "7.1.0-preview-821afcb8",
  "@metamask-previews/selected-network-controller": "26.0.1-preview-821afcb8",
  "@metamask-previews/shield-controller": "4.1.0-preview-821afcb8",
  "@metamask-previews/signature-controller": "38.0.1-preview-821afcb8",
  "@metamask-previews/storage-service": "0.0.1-preview-821afcb8",
  "@metamask-previews/subscription-controller": "5.4.0-preview-821afcb8",
  "@metamask-previews/token-search-discovery-controller": "4.0.0-preview-821afcb8",
  "@metamask-previews/transaction-controller": "62.9.1-preview-821afcb8",
  "@metamask-previews/transaction-pay-controller": "11.0.1-preview-821afcb8",
  "@metamask-previews/user-operation-controller": "41.0.1-preview-821afcb8"
}

@sahar-fehri
Copy link
Contributor Author

@metamaskbot publish-preview

@github-actions
Copy link
Contributor

Preview builds have been published. See these instructions for more information about preview builds.

Expand for full list of packages and versions.
{
  "@metamask-previews/account-tree-controller": "4.0.0-preview-009e026d",
  "@metamask-previews/accounts-controller": "35.0.1-preview-009e026d",
  "@metamask-previews/address-book-controller": "7.0.1-preview-009e026d",
  "@metamask-previews/analytics-controller": "1.0.0-preview-009e026d",
  "@metamask-previews/announcement-controller": "8.0.0-preview-009e026d",
  "@metamask-previews/app-metadata-controller": "2.0.0-preview-009e026d",
  "@metamask-previews/approval-controller": "8.0.0-preview-009e026d",
  "@metamask-previews/assets-controller": "0.0.0-preview-009e026d",
  "@metamask-previews/assets-controllers": "95.1.0-preview-009e026d",
  "@metamask-previews/base-controller": "9.0.0-preview-009e026d",
  "@metamask-previews/bridge-controller": "64.4.1-preview-009e026d",
  "@metamask-previews/bridge-status-controller": "64.4.2-preview-009e026d",
  "@metamask-previews/build-utils": "3.0.4-preview-009e026d",
  "@metamask-previews/chain-agnostic-permission": "1.4.0-preview-009e026d",
  "@metamask-previews/claims-controller": "0.4.1-preview-009e026d",
  "@metamask-previews/composable-controller": "12.0.0-preview-009e026d",
  "@metamask-previews/controller-utils": "11.18.0-preview-009e026d",
  "@metamask-previews/core-backend": "5.0.0-preview-009e026d",
  "@metamask-previews/delegation-controller": "2.0.0-preview-009e026d",
  "@metamask-previews/earn-controller": "11.0.0-preview-009e026d",
  "@metamask-previews/eip-5792-middleware": "2.1.0-preview-009e026d",
  "@metamask-previews/eip-7702-internal-rpc-middleware": "0.1.0-preview-009e026d",
  "@metamask-previews/eip1193-permission-middleware": "1.0.3-preview-009e026d",
  "@metamask-previews/ens-controller": "19.0.1-preview-009e026d",
  "@metamask-previews/error-reporting-service": "3.0.1-preview-009e026d",
  "@metamask-previews/eth-block-tracker": "15.0.0-preview-009e026d",
  "@metamask-previews/eth-json-rpc-middleware": "22.0.1-preview-009e026d",
  "@metamask-previews/eth-json-rpc-provider": "6.0.0-preview-009e026d",
  "@metamask-previews/foundryup": "1.0.1-preview-009e026d",
  "@metamask-previews/gas-fee-controller": "26.0.1-preview-009e026d",
  "@metamask-previews/gator-permissions-controller": "0.8.0-preview-009e026d",
  "@metamask-previews/json-rpc-engine": "10.2.0-preview-009e026d",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.8-preview-009e026d",
  "@metamask-previews/keyring-controller": "25.0.0-preview-009e026d",
  "@metamask-previews/logging-controller": "7.0.1-preview-009e026d",
  "@metamask-previews/message-manager": "14.1.0-preview-009e026d",
  "@metamask-previews/messenger": "0.3.0-preview-009e026d",
  "@metamask-previews/multichain-account-service": "5.0.0-preview-009e026d",
  "@metamask-previews/multichain-api-middleware": "1.2.5-preview-009e026d",
  "@metamask-previews/multichain-network-controller": "3.0.1-preview-009e026d",
  "@metamask-previews/multichain-transactions-controller": "7.0.0-preview-009e026d",
  "@metamask-previews/name-controller": "9.0.0-preview-009e026d",
  "@metamask-previews/network-controller": "28.0.0-preview-009e026d",
  "@metamask-previews/network-enablement-controller": "4.0.0-preview-009e026d",
  "@metamask-previews/notification-services-controller": "21.0.0-preview-009e026d",
  "@metamask-previews/permission-controller": "12.2.0-preview-009e026d",
  "@metamask-previews/permission-log-controller": "5.0.0-preview-009e026d",
  "@metamask-previews/phishing-controller": "16.1.0-preview-009e026d",
  "@metamask-previews/polling-controller": "16.0.1-preview-009e026d",
  "@metamask-previews/preferences-controller": "22.0.0-preview-009e026d",
  "@metamask-previews/profile-metrics-controller": "2.0.0-preview-009e026d",
  "@metamask-previews/profile-sync-controller": "27.0.0-preview-009e026d",
  "@metamask-previews/ramps-controller": "3.0.0-preview-009e026d",
  "@metamask-previews/rate-limit-controller": "7.0.0-preview-009e026d",
  "@metamask-previews/remote-feature-flag-controller": "4.0.0-preview-009e026d",
  "@metamask-previews/sample-controllers": "4.0.1-preview-009e026d",
  "@metamask-previews/seedless-onboarding-controller": "7.1.0-preview-009e026d",
  "@metamask-previews/selected-network-controller": "26.0.1-preview-009e026d",
  "@metamask-previews/shield-controller": "4.1.0-preview-009e026d",
  "@metamask-previews/signature-controller": "38.0.1-preview-009e026d",
  "@metamask-previews/storage-service": "0.0.1-preview-009e026d",
  "@metamask-previews/subscription-controller": "5.4.0-preview-009e026d",
  "@metamask-previews/token-search-discovery-controller": "4.0.0-preview-009e026d",
  "@metamask-previews/transaction-controller": "62.9.1-preview-009e026d",
  "@metamask-previews/transaction-pay-controller": "11.0.1-preview-009e026d",
  "@metamask-previews/user-operation-controller": "41.0.1-preview-009e026d"
}

* @returns A promise that resolves when initialization is complete.
*/
async initialize(): Promise<void> {
this.#initializationPromise = this.#initializeFromStorage();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we wrapping loadCacheFromStorage with initializeFromStorage? Can't we assign this.#loadCacheFromStorage() directly here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can yes; i missed cleaning this up; i think i was calling other functions initially

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.

7 participants