React Native / Mobx State Tree Persistence Boot Performance

What's the Problem?

I use Prodtype on my mobile device daily to store notes and check my to-dos. Prodtype's native app is built with Expo & React Native, and the persistence layer is Mobx State Tree (MST). Over time, the amount of saved data has caused the native app to slow down when booting up. When I had around 3,000 records, it could take 16 seconds, which was too long. I was so frustrated I'd tell myself to wait to add a note or task until I was back at my computer. I had to remember to do it or risk not saving that content. The whole point of the mobile app was to quickly document data that could be used when I was at my desk, but the slow boot was making it impossible. I knew I had to do something about the performance.

Display of splash screen

The Objective

The objective is to ensure the fastest boot time with the ability to add new notes or tasks quickly. The ideal use case on the go is to promptly save a reference to a webpage so that I can search for it later. For example, when I researched the issue "React Native boot performance," I found several articles and GitHub repos that I skimmed and saved into Prodtype while pushing through the slow boot times when launching the app. In addition to saving links, I created tasks like ensuring all packages are up to date and React Native Hermes is enabled. I had my notes and tasks, and it was time to investigate the problem and implement a solution.

Investigate

I first upgraded Expo to the latest version, 48, and ensured Hermes was enabled. As the React Native document states, "For many apps, using Hermes will result in improved start-up time, decreased memory usage, and smaller app size when compared to JavaScriptCore." I confirmed this was configured correctly, but the slow boot times persisted.

Next, I investigated the Expo SplashScreen. I disabled auto-hiding, so the splash screen would wait for the persisted data to load into Mobx State Tree. This seemed like the ideal user experience, but the long boot time during the splash screen made me want to revert and auto-hide the splash screen. I enabled the auto-hiding of the splash page but still saw the splash screen page for around 4-7 seconds. The continued splash screen delay was odd, so I disabled the persistence and found that the app would boot in less than 1 second. That is when I knew it had something to do with loading the persisted data.

Trail & Error

I went down some rabbit holes thinking it was the storage layer, so switching between React Native Async Storage to React Native MMKV. I also split the persisted data into chunks, thinking that reading them separately and loading them into the store would help, but that led to a dead end and similar slow boot times. I revisited removing the persistence layer and observing the less-than-a-second boot time with the ability to jump between routes. I found that if I delayed loading the persisted data, the app wouldn't get stuck on the splash screen, but once I applied the data via applySnapshot, the user interface would freeze. I knew the issue stemmed from loading lots of data, so I planned to lazy load that data.

Solution

The solution is to only load the necessary persisted data at boot, such as authentication, user profile, and workspaces, and everything else could be lazily loaded. At first, this approach didn't work. It was still blocking user interactions for about 7 seconds. I added 100 milliseconds timeouts between loading each record to the sub-store, and surprisingly this worked very well. The app boots fast, I can add notes or tasks quickly, and the persisted data lazily loads. This doesn't solve the underlying issue of why MST's applySnapshot blocks the thread, but it does provide a better user experience when I'm trying to add content quickly. Lastly, when the data is persisted, I added a check to ensure all persisted data is loaded first and only then continue to persist data to the device.

PersistStore Example

export const PersistStore = async ({
  name,
  store,
  bootlist,
}: PersistOptions) => {
  onSnapshot(store, (snapshot) => {
    // This tells MST to only persist the data when all cache as be loaded
    if (store.cacheReady === true) {
      Storage.save(name, snapshot);
    }
  });

  // this delay ensured splash screen would hide and initial route would display
  await timeout(100);

  return Storage.load(name).then(async (original) => {
    // this is will be the initial persisted data loaded, via bootlist: [user, auth, workspaces]
    const snapshot: any = {
      booted: true,
    };
    // collections is the persisted data that will be lazy loaded after the initial persisted data
    const collections: any = {};

    for (const storeName in original) {
      const value = original[storeName];

      // if in boot list, load all the data (ie [user, auth, workspaces])
      if (bootlist && bootlist.includes(storeName)) {
        snapshot[storeName] = value || {};
      } else {
        // lazy load collections by omitting it from the initial persisted data
        snapshot[storeName] = omit(value, "collection");
        if (value.collection) {
          // save the collections by storeName
          collections[storeName] = value.collection;
        }
      }
    }
    // load initial persisted data
    applySnapshot(store, snapshot);

    // add some delay do doesn't block user input
    await timeout(100);

    // load collections
    for (const storeName in collections) {
      const collection = collections[storeName];
      // get the sub store by storeName
      const subStore = (store as any)[storeName];
      const subStoreModel = subStore.model;
      // lazy load the data
      Object.values(collection).forEach(async (item: any) => {
        const exists = subStore.collection.get(item.id);
        if (exists) {
          // if item already exists merge the data
          exists.set(item);
        } else {
          // if item doesn't exist create it
          subStore.collection.set(item.id, subStoreModel.create(item));
        }
        // slowly load each item
        await timeout(100);
      });
    }
    // enable persisting data
    store.setCacheReady(true);
  });
};

Implementation Notes

  • Each of my sub-stores has a collection map of their models

  • They have a reference of its model so it can be created when loading data into the sub-store

// Example Sub Store
export const SubStore = types
  .model("SubStore", {
    collection: types.map(Model),
  })
  .volatile((self) => {
    return {
      _model: Model,
    };
  })

export const Model = types
  .model("Model")
  .props({
    id: types.identifier,
    createdAt: types.maybe(types.string),
    updatedAt: types.maybe(types.string),
    name: types.maybe(types.string),
  })

Demonstrate fast boot performance

Conclusion

The best result was to lazy load the persisted data. This yields the ideal outcome, but I understand that I must solve the core issue. I plan to revisit MST to identify the problem and devise a solution that addresses the blocking during applySnapshot. For now, it solves my objective, which is to ensure I can quickly save notes and tasks via mobile. If you want to checkout the Prodtype app head over to https://prodtype.com.