-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Description
Proposal from @TMisiukiewicz
Background
Onyx is the state management layer powering the Expensify app. It sits between a persistent storage backend and React components that subscribe to keys via useOnyx. Since its inception, Onyx has used a lazy-loading strategy: when a component subscribes to a key, Onyx checks its in-memory cache first, and falls back to a storage read if the key is absent. To prevent unbounded memory growth, a complex LRU eviction system was built on top — tracking access order, maintaining allow-lists and block-lists of evictable keys, and enforcing a 50k cached key ceiling.
The app's init() flow today runs two parallel operations before the deferred init task resolves and subscribers begin receiving data: it seeds the eviction allow-list by iterating all storage keys, and it multi-gets only the keys present in initialKeyStates from storage. All other keys are loaded on-demand, one batch per subscribing component group, each batch triggering its own round of subscriber notifications. The Expensify app's largest real-world state we are aware of sits at approximately 33k keys and ~45 MB of serialized data.
In practice, lazy-loading is an over-optimization: by the time the app finishes initializing, 99.99% of storage keys end up loaded into cache on-demand anyway. The eviction system never meaningfully reduces memory usage because nearly every key is actively subscribed to. The complexity exists to solve a problem that doesn't materialize.
Problem
When Onyx is initializing and multiple components subscribe to overlapping or dependent keys before any data is in cache, if those keys are loaded lazily in separate per-component storage fetches, then each fetch independently resolves and fires subscriber notifications with a partial view of the world, causing computationally expensive derived selectors (such as useSidebarOrderedReports) to execute multiple times with incomplete data before the final consistent state is available.
Solution
Replace the lazy per-key cache population strategy with a single bulk read of the entire storage database during init(), and populate the cache completely before the deferred init gate opens. By the time the first subscriber fires, the cache is complete — every key is present, every read is a synchronous cache hit, and no component ever triggers a storage fallback. This eliminates the batched on-demand loading that caused repeated partial-data notifications. The largest known real-world state (~33k keys, ~45 MB) sits well below the old eviction ceiling (50k), and measured memory consumption shows no significant difference between lazy-loaded and bulk-loaded states.
As a consequence of the cache being fully populated after init and with our findings that the cache eviction isn’t providing any benefits, the LRU eviction system becomes unnecessary and is removed along with its configuration surface (evictableKeys, maxCachedKeysCount, canEvict).
Beyond the immediate performance gains, loading the full state into memory at startup opens the door to several future improvements: structural sharing in the cache layer, a synchronous API for reading Onyx data, and simplification of the connection framework. Once these pieces are in place, useOnyx has the possibility to become a simple, fast hook — lightweight enough to call directly inside individual list items, a pattern that is currently impractical due to the hook's and overall framework complexity.
Measured Results (33k keys / ~45 MB)
| Metric | Before | After | Improvement |
|---|---|---|---|
| Web ManualAppStartup | 3.8s | 3.2s | 16% |
| iOS ManualAppStartup | 9.5s | 7.0s | 34% |
| SidebarOrderedReportsContextProvider | 3.6s | 1.6s | 56% |
| JS thread occupancy (iOS) | ~10s | ~7.5s | 25% |
| Memory | — | — | No significant regression |
Metadata
Metadata
Labels
Type
Projects
Status