Work begins with a cache
We are already sending data to the client application, doing this during page loading as early as possible. This means that the only faster way to deliver data would be one that does not include any steps involved in requesting information from a client or sending it to a client on the server’s initiative. This can be done using this approach to page formation, in which the cache comes to the fore. This, however, means that we will have to, although very briefly, show the user outdated information. Using this approach, after loading the page, we immediately show the user a cached copy of his feed and stories, and then, after the latest data is available, we replace all this with such data.
We use Redux to manage instagram.com status. As a result, the overall implementation plan of the above scheme looks like this. We store a subset of the Redux repository on the client, in the indexedDB table, populating this repository the first time the page loads. However, working with indexedDB, downloading data from the server, and user interaction with the page are asynchronous processes. As a result, we may run into problems. They consist in the fact that the user is working with the old cached state, and we need to make the user’s actions apply to the new state when receiving it from the server.
For example, if we use the standard mechanisms for working with the cache, we may encounter the following problem. We are starting parallel loading of data from the cache and from the network. Since the data from the cache will be ready faster than the network data, we show it to the user. The user then, for example, likes the post, but after the server’s response, which contains the latest information, arrives at the client, this information overwrites the information about the liked post. In this fresh data there will be no information about the likes that the user has set the cached version of the post. Here is how it looks.
Race state that occurs when a user interacts with cached data (Redux actions are highlighted in green, status is gray)
To solve this problem, we needed to change the cached state in accordance with the user's actions and save information about these actions, which would allow us to reproduce them as applied to the new state from the server. If you have ever used Git or another version control system, then this task may seem familiar to you. Suppose that the cached state of the tape is the local repository branch, and the server response with the latest data is the master branch. If so, then we can say that we want to perform the relocation operation, that is, we want to take the changes recorded in one branch (for example, likes, comments, and so on) and apply them to another.
This idea leads us to the following system architecture:
- When the page is loaded, we send a request to the server to download new data (or wait for it to be sent at the server’s initiative).
- Create an intermediate (staged) subset of the Redux state.
- In the process of waiting for data from the server, we save the submitted actions.
- After receiving data from the server, we perform actions with the new data and play back the stored actions on the new data, applying them to the intermediate state.
- After that, we commit the changes and replace the current state with an intermediate one.
Solving a problem caused by a race condition using an intermediate state (Redux actions are highlighted in green, status is gray)
Thanks to the intermediate state, we can reuse all existing reducers. This, in addition, allows you to store an intermediate state (which contains the latest data) separately from the current state. And, since work with the intermediate state is implemented using Redux, it’s enough for us to send actions to use this state!
API
The intermediate state API consists of two main functions. This is
stagingAction
and
stagingCommit
:
function stagingAction( key: string, promise: Promise<Action>, ): AsyncAction<State, Action> function stagingCommit(key: string): AsyncAction<State, Action>
There are several other functions that are intended, for example, to undo changes and to handle border cases, but we will not consider them here.
The
stagingAction
function accepts a promise resolving to an event that needs to be sent to an intermediate state. This function initializes the intermediate state and monitors the actions that have been sent since its initialization. If we compare this with version control systems, it turns out that we are dealing with the creation of a local branch. The ongoing actions will be queued and applied to the interim state after new data arrives.
The
stagingCommit
function replaces the current state with an intermediate one. Moreover, if it is expected that some asynchronous operations are completed that are performed on an intermediate state, the system will wait for these operations to complete before replacement. This is similar to the relocation operation, when local changes (from the branch that stores the cache) are applied on top of the master branch (on top of new data received from the server), which leads to the fact that the local version of the state is relevant.
In order to enable the system of work with an intermediate state, we wrapped the root reducer in the extender capabilities of the reducer. It processes the
stagingCommit
action and applies previously saved actions to the new state. In order to take advantage of all this, we only need to send actions, and everything else will be done automatically. For example, if we want to load a new tape and bring its data into an intermediate state, we can do something like this:
function fetchAndStageFeed() { return stagingAction( 'feed', (async () => { const {data} = await fetchFeedTimeline(); return { type: FEED_LOADED, ...data, }; })(), ); } // store.dispatch(fetchAndStageFeed()); // , stagingCommit, // 'feed' // store.dispatch(stagingCommit('feed'));
The use of a rendering approach for the feed and stories, in which the cache comes to the fore, has accelerated the output of materials by 2.5% and 11%, respectively. This, in addition, contributed to the fact that, in the perception of users, the web version of the system came closer to Instagram clients for iOS and Android.
Dear readers! Do you use any approaches to optimize caching when working on your projects?