Skip to content

[Onyx Audit] Batching mechanism feels overcomplicated #71207

Closed
1 of 1 issue completed
Closed

Description

@fabioh8010

Overview

This issue is part of our current effort to audit the performance and scalability of Onyx.

During the Step 1: Objective Analysis we gathered a list of Perceived Problems we have exclusively about Onyx, that is, the problems in Onyx we imagine or have some evidences that that are pain points / performance bottenecks to Onyx, but we need more analysis to confirm if they really are and, if so, how much of a problem they are currently.

The main goal right now is to deep dive into each Perceived Problem, investigate/audit it and come up with findings, which we will use for the next discussions.

Problem

For Onyx.set() and Onyx.merge() functions, the process is basically this one:

  1. Changes are broadcasted to subscribers with OnyxUtils.broadcastUpdate().
  2. At the same, Storage.setItem()/Storage.mergeItem() is called to store the changes (according to the code this approach prioritizes fast UI changes without waiting for data to be stored in device storage). If the value is being removed, OnyxUtils.remove() will be called instead and do the same steps below.
  3. OnyxUtils.broadcastUpdate() notifies subscribers and writes current value to cache. OnyxUtils.scheduleSubscriberUpdate() is called to schedule the subscriber update.
  4. OnyxUtils.scheduleSubscriberUpdate() will first call Onyx.keyChanged() in the next tick to notify the Onyx.connect() subscribers. Then, it will use OnyxUtils.batchUpdates() to batch a Onyx.keyChanged() call that notify the withOnyx()/useOnyx() subscribers. Finally, it waits for both OnyxUtils.maybeFlushBatchUpdates() and the first Onyx.keyChanged() to resolve.

The major difference is that Onyx.merge() will queue multiple changes to the same Onyx key, and later batch them into just one.

For Onyx.update(), SET and MERGE operations are queued by their Onyx keys. Collection key changes are grouped to be used into Onyx.mergeCollection() calls.

This overall system of broadcasting and merging looks too complex at a first glance, and I'm not really sure we still need it with React concurrent and useOnyx().

Action Items

  1. Analyse how other state management libraries handle batching/setting/merging of multiple store updates, and compare with ours.
  2. Understand why we have all these complexities in our internals and evaluate what we could change or simplify.

Relevant Links

Step 1 Canvas: https://expensify.slack.com/docs/T02P6RS2ZK7/F09B668M1MG

Slack thread: https://expensify.slack.com/archives/C05LX9D6E07/p1757416584331869

Activity

  1. VickyStash commented on Oct 2, 2025

    @VickyStash
    Contributor

    Hi, I'm Viktoryia from Callstack - expert contributor group - and I would like to work on this issue!
    It would be the great opportunity for me to continue these investigations: #66799 (comment)
    I should have space to work on it in ~1-2 weeks

  2. VickyStash commented on Oct 10, 2025

    @VickyStash
    Contributor

    First of all, I want to say that it feels like the current batching doesn't make sense anymore, after withOnyx HOC was removed.

    The batching logic was always working only for withOnyx subscribers!


    Let's take step back and see how it worked before withOnyx was removed.
    See how scheduleSubscriberUpdate looked:

    function scheduleSubscriberUpdate<TKey extends OnyxKey>(
        key: TKey,
        value: OnyxValue<TKey>,
        previousValue: OnyxValue<TKey>,
        canUpdateSubscriber: (subscriber?: Mapping<OnyxKey>) => boolean = () => true,
    ): Promise<void> {
        const promise = Promise.resolve().then(() => keyChanged(key, value, previousValue, canUpdateSubscriber, true, false));
        batchUpdates(() => keyChanged(key, value, previousValue, canUpdateSubscriber, false, true));
        return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined);
    }
    
    1. In this line:
    const promise = Promise.resolve().then(() => keyChanged(key, value, previousValue, canUpdateSubscriber, true, false));
    

    The keyChanged was called with notifyConnectSubscribers=true and notifyWithOnyxSubscribers=false, so connect subscribers, including useOnyx subscribers, are notified right away.

    1. And then in this line:
     batchUpdates(() => keyChanged(key, value, previousValue, canUpdateSubscriber, false, true));
    

    The keyChanged was called with notifyConnectSubscribers=false and notifyWithOnyxSubscribers=true.
    So notifying of the withOnyx subscribers was batched!


    In comparison, right now:
    https://github.com/Expensify/react-native-onyx/blob/413cb1ad4f23b46aa3c35990c82e5bbe37bf15a1/lib/OnyxUtils.ts#L821-L823

    1. We call keyChanged with notifyConnectSubscribers=true, so notifying connect subscribers right away
    const promise = Promise.resolve().then(() => keyChanged(key, value, canUpdateSubscriber, true));
    
    1. And then batch call keyChanged with notifyConnectSubscribers=false.
    batchUpdates(() => keyChanged(key, value, canUpdateSubscriber, false));
    

    But keyChanged notify no subscibers in this case (cause all of our subscribers are connect subscribers).
    https://github.com/Expensify/react-native-onyx/blob/413cb1ad4f23b46aa3c35990c82e5bbe37bf15a1/lib/OnyxUtils.ts#L730-L732

  3. VickyStash commented on Oct 13, 2025

    @VickyStash
    Contributor

    UPD:
    Today I had some capacity and decided to experiment and get rid of the current batching at all (based on my comment). Aaaand, I see some tests failing, which means I'm missing something, so I'm trying to understand all of the logic and what's happening under the hood.

    The test which is failing for me: should replace the old value after a null merge in the top-level object when batching updates.

    I want to confirm one thing, is it expected that the Onyx.connect callback is called twice with the same data when waitForCollectionCallback is true. You can check with this simple jest test (it will fail):

        it("shouldn't call a collection connection twice when setting a connection value", async () => {
            const mockCallback = jest.fn();
            connection = Onyx.connect({
                key: ONYX_KEYS.COLLECTION.TEST_UPDATE,
                waitForCollectionCallback: true,
                callback: mockCallback,
            });
    
            await Onyx.set(`${ONYX_KEYS.COLLECTION.TEST_UPDATE}1`, 'test');
    
            expect(mockCallback).toHaveBeenCalledTimes(1);
        });
    
  4. VickyStash commented on Oct 16, 2025

    @VickyStash
    Contributor

    Updates:

    • Finally figured out the reason for failing tests after removing the batching mechanism. It's not due to the removal of the batching mechanism on its own, but due to the removal of 0 setTimout. Without this "macrotask barrier" the outdated cache results are returned in callbacks in some cases.

    I've prepared a Draft PR Expensify/react-native-onyx#689 with the removed batch mechanism to simplify the testing and other experiments.

    TODOs:

    • I want to try to figure out why we need to have a macrotask to make the cache mechanism work as expected.
    • Check performance tests
    • Test the E/App over onyx without batching mechanism
  5. VickyStash commented on Oct 20, 2025

    @VickyStash
    Contributor

    Updates:

    Finally figured out the reason for failing tests after removing the batching mechanism. It's not due to the removal of the batching mechanism on its own, but due to the removal of 0 setTimout. Without this "macrotask barrier" the outdated cache results are returned in callbacks in some cases.

    I've investigated more why cache doesn't work as expected if I remove the 0 setTimeout inside the scheduleSubscriberUpdate/scheduleNotifyCollectionSubscribers.

    1. The reason is that when the Onyx.connect is called we call the subscribeToKey.
    2. Inside the subscribeToKey function we have deffered logic
    3. In this logic if waitForCollectionCallback is true we call the callback with cahced data if it exists.

    Problem: without extra setTimout in the scheduleSubscriberUpdate/scheduleNotifyCollectionSubscribers there is a race condition, when the connection subscriber and cache is firstly updated by the data from scheduleSubscriberUpdate and then with outdated data from subscribeToKey callback.

    So, to sum up what I have for now:

    • It looks like current batching logic doesn't used in the way it was created to be used (for withOnyx). More details here.
    • setTimeout inside maybeFlushBatchUpdates helps to prevent cache race condition (we can use just setTimeout without any bacthing logic, the effect seems to be the same).

    TODOs:

    • Check performance tests
    • Test the E/App over onyx without batching mechanism
  6. VickyStash commented on Oct 21, 2025

    @VickyStash
    Contributor
    • I've tested the E/App over the branch without a batching mechanism, and overall there is no huge difference (which I think is expected), sometimes the duration is a little longer/shorter over some flows, but mostly it feels the same. Example, app restart:
    Image
    • All jest tests have passed.
  7. VickyStash commented on Oct 21, 2025

    @VickyStash
    Contributor

    Summary of what I have for now:

    • Current batching logic that exists inside the onyx lib was created for withOnyx HOC, and has no batching effect right now. More details here.
    • setTimeout inside maybeFlushBatchUpdates helps to prevent cache race condition (we can use just setTimeout without any bacthing logic, the effect seems to be the same). More details here.
    • As following from the above, removing of the current batching doesn't affect the E/App a lot, it tests pretty the same: details here.

    TODOs:
    Right now, useOnyx hook subscribers have no additional batching. In the E/App the concurrent rendering is enabled, which means some updates get batched automatically. But it not always happens for the complex Onyx.udates operations, so I want to:

    • define cases where the lack of additional batching for useOnyx subscribers is causing problems
    • consider, how it can be solved and if we should implement the custom batching for useOnyx.
  8. fabioh8010 commented on Oct 22, 2025

    @fabioh8010
    ContributorAuthor
    • I've tested the E/App over the branch without a batching mechanism, and overall there is no huge difference (which I think is expected), sometimes the duration is a little longer/shorter over some flows, but mostly it feels the same. Example, app restart:
    Image * All jest tests have passed.

    Yes, I was discussing with @VickyStash and I think this is pretty much the direction we want to move – strip away this whole complex batching logic from Onyx and just leave to React to handle the updates itself.

    I think if it’s like >90% of things are working and we just have a few issues, maybe it’s just something we can adjust in E/App
    If the macro tasks hacks are needed only for tests – even better.

    We should be able to remove this, I'm pretty sure the other state management libraries don't need this whole stuff.

  9. VickyStash commented on Oct 22, 2025

    @VickyStash
    Contributor

    Updates:
    Today I was mainly focused on different testing and experiments.

    Summary for now:

    • Does current batching logic makes sense/any affect on the app - NO, it was created for withOnyx subscribers and has no batching affect rigth now
    • Can we remove it? - YES, but we should keep the extra macrotask to avoid a cache race condition
    • Can we remove the macrotask at all? - Still need to define, as it looks like the cache has some problems in the current version as well. If I'm right, this issue will be solved separately and then we can get rid of all of this!
    • Since current batching logic doesn't work, is there any side effects in the app? - YES, complex Onyx.updates are applied in several renders, so there can be the situation when success data is applied a little earlier then the API response, etc.
    simple component to check the issue
    /* eslint-disable */
    import React, {useEffect} from 'react';
    import type {OnyxUpdate} from 'react-native-onyx';
    import Onyx from 'react-native-onyx';
    import useOnyx from '@hooks/useOnyx';
    import ONYXKEYS from '@src/ONYXKEYS';
    import {createRandomReport} from '../../tests/utils/collections/reports';
    import Text from './Text';
    
    function TestComponent() {
        const [reportMetadata1] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}1`);
        const [report1] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}1`);
    
        useEffect(() => {
            console.log('RERENDER', {report1, reportMetadata1});
        }, [report1, reportMetadata1]);
    
        const applyTestUpdates = () => {
            const optimisticReports: Record<string, unknown> = {};
            for (let i = 1; i < 5; i++) {
                optimisticReports[`${ONYXKEYS.COLLECTION.REPORT}${i}`] = createRandomReport(i);
            }
    
            const updates: OnyxUpdate[] = [
                {
                    onyxMethod: Onyx.METHOD.MERGE,
                    key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}1`,
                    value: {
                        isLoadingInitialReportActions: false,
                        isLoadingOlderReportActions: false,
                        hasLoadingOlderReportActionsError: false,
                        isLoadingNewerReportActions: false,
                        hasLoadingNewerReportActionsError: false,
                    },
                },
                {
                    onyxMethod: Onyx.METHOD.MERGE_COLLECTION,
                    key: ONYXKEYS.COLLECTION.REPORT,
                    value: optimisticReports,
                },
            ];
    
            Onyx.update(updates);
        };
    
        return (
            <Text
                style={{backgroundColor: 'yellow'}}
                onPress={applyTestUpdates}
            >
                test ONYX.UPDATE with setCollection
            </Text>
        );
    }
    
    export default TestComponent;
    
    • Should it be solved with some kind of new batching logic? - Not sure about it, I feel like we should improve complex operations (setCollection, mergeCollection) instead so react batching can handle it on it's own

    TODOs:

    • Finilise investigations and suggest action items
  10. VickyStash commented on Oct 23, 2025

    @VickyStash
    Contributor

    Should it be solved with some kind of new batching logic? - Not sure about it, I feel like we should improve complex operations (setCollection, mergeCollection) instead so react batching can handle it on it's own

    I've focused on this part today. I've narrowed down that several rerendering from one Onyx.update happen if:

    • there is a mergeCollection + simple operation (one item merge/set) inside one update
    • several merges to one collection that are combined inside one mergeCollection + simple operation (one item merge/set) inside one update

    I've investigated it and it should be possible to fix the rerenders by updating how mergeCollectionWithPatches notifies the subscribers. Basically, the problem is that we not just notify subscriber as in the simple merge or set command, but additionally wait for previousCollection value: see this part of the code.
    So due to this additional promise the subscribers related to mergeCollection updates can be notified a little later.
    If we fix that, no additional custom batching is needed.

    If I update the logic to just (just a straightforward idea for testing):

    cache.merge(finalMergedCollection);
    const promiseUpdate = scheduleNotifyCollectionSubscribers(collectionKey, finalMergedCollection);
    

    It works okay.
    I guess we still can use the "previousCollectionPromise", just maybe get them before calling any subscribers (from Onyx.update). Or we can consider if we need it at all.


    Overall, I think I will prepare a summary report for this one tomorrow.

  11. VickyStash commented on Oct 24, 2025

    @VickyStash
    Contributor

    Summary

    1. Does current batching logic makes sense/any affect on the app?

    ❌ No, it was created and designed to work for withOnyx HOC subscribers only and has no batching affect rigth now!

    Details: #71207 (comment)

    2. Can we then remove the current batching logic from Onyx?

    ✅ Yes! You can check the draft branch with the removed batching Expensify/react-native-onyx#689
    I've made some tests over the branch and haven't noticed any changes in the app logic, more details about test results here: #71207 (comment)

    Important note❗

    While removing all batching logic, I needed to leave the "macrotask barrier" inside scheduleSubscriberUpdate / scheduleNotifyCollectionSubscribers.
    Otherwise, some jest tests were failing. Why? - Due to the cache race condition, I've explained it more here: #71207 (comment)

    The thing is that even in the current app version with the current onyx, I was able to reproduce similar behaviour in the runtime. The issue is reproduced only during direct Onyx.connect usage. With useOnyx it's handled.

    Simple reproduction example (use in the E/App)

    console.log('CALLBACK called with value:', result); isn't called with someKey: 'someValueChanged', though it's the last value that was applied.

    /* eslint-disable */
    import React, {useEffect} from 'react';
    import Onyx from 'react-native-onyx';
    import ONYXKEYS from '@src/ONYXKEYS';
    import Text from './Text';
    
    let result: unknown;
    Onyx.connect({
        key: ONYXKEYS.COLLECTION.TEST_UPDATE,
        waitForCollectionCallback: true,
        callback: (value) => {
            console.log('CALLBACK called with value:', result);
            result = value;
        },
    });
    
    function TestComponent1() {
        const applyData = async () => {
            await Onyx.multiSet({
                [`${ONYXKEYS.COLLECTION.TEST_UPDATE}entry1`]: {
                    id: 'entry1',
                    someKey: 'someValue',
                } as any,
            });
    
            // Removing the entire object in this merge.
            // Any subsequent changes to this key should completely replace the old value.
            Onyx.merge(`${ONYXKEYS.COLLECTION.TEST_UPDATE}entry1`, null);
    
            // This change should completely replace `${ONYX_KEYS.COLLECTION.TEST_UPDATE}entry1` old value.
            Onyx.merge(`${ONYXKEYS.COLLECTION.TEST_UPDATE}entry1`, {
                someKey: 'someValueChanged',
            });
        };
    
        useEffect(() => {
            applyData();
        }, []);
    
        return <Text style={{backgroundColor: 'orange'}}>Tests how Onyx.connect works</Text>;
    }
    
    export default TestComponent1;
    
    

    Which makes me think that maybe the problems exist even now, just not caught by jest test.

    I think there can be two ways we can go:
    I. Leave the extra macrotask for now -> Fix the cache race condition without the extra macrotask -> remove the extra macrotask.
    II (more risky). Remove the macrotask -> Check if there are any bugs in the app due to cache race condition. I did this experiment on my own, and the app worked okay for basic scenarios for me. Besides that, the perfomance metrics improved!

    3. Since current batching logic doesn't work, is there any side effects in the app?

    Yes, and it was reported several times before. Complex Onyx.updates are applied in several renders, so there can be the situation when success data is applied a little earlier then the API response, etc.

    Simple reproduction example (use in the E/App)

    console.log('RERENDER', {report1, reportMetadata1}); will be called firsly with reportMetadata1 updates, and then with report1 updates, though they are applied in one Onyx.update.

    /* eslint-disable */
    import React, {useEffect} from 'react';
    import type {OnyxUpdate} from 'react-native-onyx';
    import Onyx from 'react-native-onyx';
    import useOnyx from '@hooks/useOnyx';
    import ONYXKEYS from '@src/ONYXKEYS';
    import {createRandomReport} from '../../tests/utils/collections/reports';
    import Text from './Text';
    
    function TestComponent() {
        const [reportMetadata1] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}1`);
        const [report1] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}1`);
    
        useEffect(() => {
            console.log('RERENDER', {report1, reportMetadata1});
        }, [report1, reportMetadata1]);
    
        const applyTestUpdates = () => {
            const optimisticReports: Record<string, unknown> = {};
            for (let i = 1; i < 50; i++) {
                optimisticReports[`${ONYXKEYS.COLLECTION.REPORT}${i}`] = createRandomReport(i);
            }
    
            const updates: OnyxUpdate[] = [
                {
                    onyxMethod: Onyx.METHOD.MERGE,
                    key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}1`,
                    value: {
                        isLoadingInitialReportActions: false,
                        isLoadingOlderReportActions: false,
                        hasLoadingOlderReportActionsError: false,
                        isLoadingNewerReportActions: false,
                        hasLoadingNewerReportActionsError: false,
                    },
                },
                {
                    onyxMethod: Onyx.METHOD.MERGE_COLLECTION,
                    key: ONYXKEYS.COLLECTION.REPORT,
                    value: optimisticReports,
                },
            ];
    
            Onyx.update(updates);
        };
    
        return (
            <Text
                style={{backgroundColor: 'yellow'}}
                onPress={applyTestUpdates}
            >
                test ONYX.UPDATE with mergeCollection
            </Text>
        );
    }
    

    I've narrowed down that several rerendering from one Onyx.update happen if:

    • there is a mergeCollection + simple operation (one item merge/set) inside one update
    • several merges to one collection that are combined inside one mergeCollection + simple operation (one item merge/set) inside one update

    I've investigated it and it should be possible to fix the rerenders by updating how mergeCollectionWithPatches notifies the subscribers, more details here: #71207 (comment)
    So we won't need any additional custom batching.

    Suggested action items

    1. Remove custom batching from onyx
    2. Fix cache race condition issue
    3. Improve how mergeCollectionWithPatches notifies the subscribers, so it's aligned with simple operations.
  12. fabioh8010 commented on Oct 27, 2025

    @fabioh8010
    ContributorAuthor

    Great investigations @VickyStash !

    I'm really happy to see the current batching mechanism isn't being useful to Onyx and it's something we could get rid off completely in Onyx v2 🎉

    I think we are in a good place for now, do you think there's more things to investigate here or we can wrap-up this one?

  13. 5 remaining items

  14. VickyStash commented on Jan 9, 2026

    @VickyStash
    Contributor

    Updates:
    The first proposal was posted: https://expensify.slack.com/archives/C05LX9D6E07/p1767953842559849. As soon as it gets enough feedback, I'll move forward.

  15. fabioh8010 commented on Jan 22, 2026

    @fabioh8010
    ContributorAuthor

    Updates:

    • Latest update here. Once it's done @VickyStash will work in the remaining proposals here.
  16. VickyStash commented on Jan 26, 2026

    @VickyStash
    Contributor

    Update:
    I've started the experiments for the second proposal.
    I want to try to get rid of the extra micro/macro tasksduring subscribers' updates.
    If you want to check experiments, you can take a look at the -> Draft PR.
    I've started to test my updates over the E/App, and see some jest tests failing there, so I'll need to investigate it more to find the root cause. Though some of the failing looks logical, for example:

    Image
  17. VickyStash commented on Jan 30, 2026

    @VickyStash
    Contributor

    Hey, I've switched to another issue this week, going to return to this one on the next week!

  18. VickyStash commented on Feb 2, 2026

    @VickyStash
    Contributor

    Update:

    Profiles comparison

    Report reload:

    Image

    Switching to Expenses in the Reports tab:

    Image
  19. VickyStash commented on Feb 13, 2026

    @VickyStash
    Contributor

    Updates:

    • Yesterday and today has been working on preparing a proposal Remove asynchronous scheduling from Onyx subscriber notifications for internal review!
  20. VickyStash commented on Feb 16, 2026

    @VickyStash
    Contributor

    Update:

    • Posted a proposal to internal review
  21. VickyStash commented on Feb 18, 2026

    @VickyStash
    Contributor

    Updates:

    • I've posted the second proposal externally and it was approved. I'm going to open the PR for review soon.
  22. VickyStash commented on Mar 2, 2026

    @VickyStash
    Contributor

    Updates:

  23. VickyStash commented on Mar 31, 2026

    @VickyStash
    Contributor
  24. VickyStash commented on Apr 23, 2026

    @VickyStash
    Contributor
  25. fabioh8010 commented on May 4, 2026

    @fabioh8010
    ContributorAuthor
  26. mountiny commented on May 8, 2026

    @mountiny
    Contributor

    Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions