Skip to content

Add bulk-load path to OnyxCache to speed up Onyx.init() #98553

Description

@melvin-bot

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 OwnerCurrent Issue Owner: @mkhutornyi

Metadata

Metadata

Labels

Type

No type

Projects

Status
HIGH

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions