Skip to content

Conversation

@andrepimenta
Copy link
Member

@andrepimenta andrepimenta commented Nov 19, 2025

Add StorageService for Large Controller Data

Explanation

What is the current state and why does it need to change?

Current state: MetaMask Mobile Engine state is 10.79 MB, with 92% (9.94 MB) concentrated in just 2 controllers:

  • SnapController: 5.95 MB of snap source code (55% of total state)
  • TokenListController: 3.99 MB of token metadata cache (37% of state)

This data is rarely accessed after initial load but stays in Redux state, causing:

  • Slow app startup (parsing 10.79 MB on every launch)
  • High memory usage (all data loaded even if not needed)
  • Slow persist operations (up to 6.26 MB written per controller change)

Why change: Controllers need a way to store large, infrequently-accessed data outside of Redux state while maintaining platform portability and testability.

What is the solution and how does it work?

New package: @metamask/storage-service

A platform-agnostic service that allows controllers to offload large data from state to persistent storage via messenger actions.

How it works:

  1. Controllers call StorageService:setItem via messenger to store large data
  2. StorageService saves to platform-specific storage (FilesystemStorage on mobile, IndexedDB on extension)
  3. StorageService publishes events (StorageService:itemSet:{namespace}) so other controllers can react
  4. Controllers call StorageService:getItem to load data lazily (only when needed)

Storage adapter pattern:

// Service accepts platform-specific adapter (like ErrorReportingService)
const service = new StorageService({
  messenger,
  storage: filesystemStorageAdapter, // Mobile provides this
});

Example controller usage:

// Store data (out of state)
await this.messenger.call(
  'StorageService:setItem',
  'MyController',
  'data-key',
  largeData,
);

// Load on demand (lazy loading)
const data = await this.messenger.call(
  'StorageService:getItem',
  'MyController',
  'data-key',
);

// Subscribe to changes (optional)
this.messenger.subscribe(
  'StorageService:itemSet:MyController',
  (value, key) => {
    // React to storage changes
  },
);

Why this architecture?

Platform-agnostic: Service defines StorageAdapter interface; clients provide implementation (mobile: FilesystemStorage, extension: IndexedDB, tests: in-memory)

Messenger-integrated: Controllers access storage via messenger actions, no direct dependencies

Event-driven: Controllers can subscribe to storage changes without coupling

Testable: InMemoryAdapter provides zero-config testing (no mocking needed)

Proven pattern: Follows ErrorReportingService design (accepts platform-specific function)

Expected impact?

With both controllers optimized:

  • State: 10.79 MB → 0.85 MB (92% reduction)
  • App startup: 92% faster state parsing
  • Memory: 9.94 MB freed
  • Disk I/O: Up to 9.94 MB less per persist

This PR adds infrastructure - Controllers can now use StorageService. Separate PRs will integrate with SnapController and TokenListController.

References

Checklist

  • I've updated the test suite for new or updated code as appropriate
    • 100% test coverage (44 tests)
    • Tests for all storage operations, namespace isolation, events, error handling
    • Real-world usage test (simulates 6 MB snap source code)
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
    • Complete README with examples
    • JSDoc for all public APIs
    • Architecture documentation in ADR
  • I've communicated my changes to consumers by updating changelogs for packages I've changed, highlighting breaking changes as necessary
    • CHANGELOG.md created for initial release
    • No breaking changes (new package)
  • I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes
    • N/A - No breaking changes
    • Consumer PRs will be created after this is merged and released

Add @metamask/storage-service package to enable controllers to store large,
infrequently-accessed data outside of Redux state.

**Problem**:
- 10.79 MB Engine state with 92% (10.18 MB) in just 2 controllers
- Slow app startup parsing large state
- High memory usage from rarely-accessed data

**Solution**:
Platform-agnostic StorageService with:
- Messenger integration (setItem, getItem, removeItem actions)
- Event system (itemSet, itemRemoved events)
- Storage adapters (FilesystemStorage, IndexedDB, InMemoryAdapter)
- Namespace isolation (storage:{namespace}:{key})

**Impact**:
- 92% state reduction potential (10.79 MB → 0.85 MB)
- Lazy loading - data loaded only when needed
- Event-driven - controllers react without coupling

**Implementation**:
- 100% test coverage (44 tests)
- Platform-agnostic design
- InMemoryAdapter for tests (zero config)
- Follows ErrorReportingService pattern

**Targets**:
- SnapController: 6.09 MB sourceCode
- TokenListController: 4.09 MB cache
- getAllKeys and clear now take namespace parameter
- Adapters handle filtering and clearing (allows platform-specific optimization)
- Removed internal key registry from core (simpler code)
- Renamed clearNamespace to clear (consistent with other methods)
- Use STORAGE_KEY_PREFIX constant ('storageService:') instead of hardcoded 'storage:'
- InMemoryAdapter implements filtering and clearing logic
- Mobile adapter can now be optimized per-platform

Benefits:
- Simpler core service (no registry maintenance)
- Platform-specific optimizations possible (IndexedDB can use IDBKeyRange)
- Clear adapter responsibilities (filtering, prefix handling)
- Consistent API (all methods take namespace first)

Tests: 100% coverage maintained, all tests passing
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.

2 participants