Background
Onyx.init() runs once per launch, loading persisted data into OnyxCache through OnyxCache.merge() before first render — the same clone-heavy path runtime Onyx.merge()/Onyx.set() use. On a heavy account (12,470 rows / 31.1MB, Samsung SM-S921B release build), median full Onyx.init() was 1528ms, with the cache-write phase alone taking 438ms (~29%).
Problem
When Onyx.init() writes persisted data into OnyxCache, every row is deep-cloned through the same merge path runtime writes use, adding ~438ms (29% of the 1528ms total) directly to the wait before the app is interactive, on every cold start.
Solution
Add OnyxCache.hydrate(), a bulk-load variant of merge() used only by Onyx.init(). It checks whether a value is provably safe to store by reference, and only clones it when it isn't:
const existing = this.storageMap[key];
if (existing !== undefined) {
this.storageMap[key] = utils.fastMerge(existing, value, {shouldRemoveNestedNulls: true, objectRemovalMode: 'replace'}).result;
return;
}
if (!utils.needsNormalization(value)) {
this.storageMap[key] = value;
return;
}
this.storageMap[key] = utils.fastMerge(undefined, value, {shouldRemoveNestedNulls: true, objectRemovalMode: 'replace'}).result;
needsNormalization() is a new read-only, non-allocating walk that returns true only if the value has nested null/undefined or the internal ONYX_INTERNALS__REPLACE_OBJECT_MARK. The rest of hydrate() mirrors merge() exactly — the only behavior change is assigning instead of cloning.
Why this is safe
At Onyx.init() time, the cache is effectively empty for every key the public API can touch. Onyx.set(), merge(), connect(), and useOnyx() are all gated behind the same deferredInitTask, which resolves only after the cache write finishes. With nothing to reconcile against, merge()'s null/undefined/replace-marker logic has nothing to do. needsNormalization() skips the clone for clean values (the common case) and falls back to today's exact fastMerge path otherwise. The only path that bypasses deferredInitTask is web's cross-tab sync listener (storage.keepInstancesSync); hydrate() handles that by checking storageMap[key] before assigning and merging onto an existing value instead of clobbering it, just like merge() would. Only the colliding key pays the merge cost.
Measured effect (25 measured cold starts per variant, heavy account):
phase baseline med with fix med delta change
OnyxInitStorageGetAll 1046ms 1095ms +49ms +4.7%
OnyxInitCacheMerge 438ms 289ms -149ms -34.0%
OnyxInitLoad (total) 1528ms 1423ms -105ms -6.9%
OnyxInitLoad drops 105ms (-6.9%) at the median, driven by CacheMerge (-149ms, -34%). StorageGetAll is untouched; its +49ms is run-to-run noise.
What ships
hydrate() and needsNormalization(), upstreamed into react-native-onyx. Includes an equivalence test asserting an identical cache state vs. merge() on an empty cache, plus fixtures for nested nulls, nested undefined, replace markers, and edge shapes.
Draft PR: Expensify/react-native-onyx#821
Reported in Slack.
Issue Owner
Current Issue Owner: @mkhutornyi
Background
Onyx.init()runs once per launch, loading persisted data intoOnyxCachethroughOnyxCache.merge()before first render — the same clone-heavy path runtimeOnyx.merge()/Onyx.set()use. On a heavy account (12,470 rows / 31.1MB, Samsung SM-S921B release build), median fullOnyx.init()was 1528ms, with the cache-write phase alone taking 438ms (~29%).Problem
When
Onyx.init()writes persisted data intoOnyxCache, every row is deep-cloned through the same merge path runtime writes use, adding ~438ms (29% of the 1528ms total) directly to the wait before the app is interactive, on every cold start.Solution
Add
OnyxCache.hydrate(), a bulk-load variant ofmerge()used only byOnyx.init(). It checks whether a value is provably safe to store by reference, and only clones it when it isn't:needsNormalization()is a new read-only, non-allocating walk that returnstrueonly if the value has nestednull/undefinedor the internalONYX_INTERNALS__REPLACE_OBJECT_MARK. The rest ofhydrate()mirrorsmerge()exactly — the only behavior change is assigning instead of cloning.Why this is safe
At
Onyx.init()time, the cache is effectively empty for every key the public API can touch.Onyx.set(),merge(),connect(), anduseOnyx()are all gated behind the samedeferredInitTask, which resolves only after the cache write finishes. With nothing to reconcile against,merge()'s null/undefined/replace-marker logic has nothing to do.needsNormalization()skips the clone for clean values (the common case) and falls back to today's exactfastMergepath otherwise. The only path that bypassesdeferredInitTaskis web's cross-tab sync listener (storage.keepInstancesSync);hydrate()handles that by checkingstorageMap[key]before assigning and merging onto an existing value instead of clobbering it, just likemerge()would. Only the colliding key pays the merge cost.Measured effect (25 measured cold starts per variant, heavy account):
OnyxInitLoaddrops 105ms (-6.9%) at the median, driven byCacheMerge(-149ms, -34%).StorageGetAllis untouched; its +49ms is run-to-run noise.What ships
hydrate()andneedsNormalization(), upstreamed intoreact-native-onyx. Includes an equivalence test asserting an identical cache state vs.merge()on an empty cache, plus fixtures for nested nulls, nestedundefined, replace markers, and edge shapes.Draft PR: Expensify/react-native-onyx#821
Reported in Slack.
Issue Owner
Current Issue Owner: @mkhutornyi