This post first appeared on my personal blog

The first half of 2020 passed faster than any other year. Twinkling of an eye has arrived in the second half of the year, finally, I was in business again.

Before writing this article, I searched “optimistic update” on Baidu, Zhihu, Digg gold and other platforms, and found that there were only a handful of relevant content, and there were a lot of “optimistic lock”.

That’s good. Let me fill in the blanks.

What is an “optimistic Update”

Typically, after making a CRUD request, the client needs to wait for the server to return before updating the view. The logic is perfectly sound. There’s nothing wrong with it. The only drawback is that during the time waiting for the response of the server, the client can only wait for nothing. Intuitively, the user will inevitably have a perceived delay between the end of the interaction and the result. Those who pursue user experience will display Loading at this time to let users know that the system is still running.

If you’ve ever tried a Google home product, you’ll be amazed at the responsiveness of the interaction, the thrill of pressing a button and getting results instantly, as if everything happens locally and you don’t have to wait for the interface to respond. It’s not because Google is Google, the Internet giant that owns the browser, the web protocol, the V8 engine, and so on. Their solution is very simple: without waiting for the interface to return, the request is sent and the result of the interaction is directly reflected in the view.

This is called “optimistic updating” : The client assumes that the request will succeed, so it updates the view without waiting for the interface to return.

So optimistic, is it really a big man?

Optimism is good, but we should not be blindly optimistic. Not all operations can be implemented in the “optimistic update” way, but there are a few prerequisites:

  • Operation success rate is very high, the probability is not to fail.
  • Normal interface response takes a short time and does not involve large-scale computing or I/O.
  • The operation is reversible and can be undone if it fails.

Things like sending a message or deleting a file are considered “optimistic updates” that can dramatically improve the user experience.

Can you accept waiting half a second for every wechat message to appear on the screen? Did you notice that the default action of deleting to the recycle bin in Windows 10 no longer pops up in the confirmation box?

Conversely, operations related to file transfer and authentication are not suitable for optimistic updates.

Can you imagine the thunder download has not even started to show that the download is complete? Or when using pay treasure to pay, first show pay success, and then tell you insufficient balance to be withdrawn?

Be optimistic and pessimistic

In a good network environment (such as Wifi, 4G), the return of the interface is likely to be successful. Therefore, the client can make a request and assume that the return of the interface is successful. Now that you know the result, you don’t have to wait for the interface to proceed directly.

However, this series of operations, all based on an assumption, since the assumption, that is bound to be wrong. To ensure that the logic of the system is correct, we must be prepared for this situation.

Therefore, even though the “optimistic update” does not wait for the interface to return, it does not mean that we are completely indifferent to the return of the interface. We still need to wait for the return of the interface to verify that the previous operation really works. With high probability, the return result is consistent with the expectation, so we generally do not need extra processing; But if there is a deviation, you need to undo the previous action and inform the user.

Build your own optimistic update mechanism

architecture

First, let’s design the basic architecture for optimistic updates.

Roughly the same structure as Flux, it is still a reducer (state, action) => state process, but the actions in the middle are not one-time and need to be saved in a queue until the status is confirmed before being destroyed.

An optimistically updated Action is different from the typical Action we see in Redux or Vuex. Since it is not one-off, it requires additional information to record the current state. The possible structure is as follows:

interface Action {
  // Normal structure
  type: stringpayload? :any

  // Optimistic update attached
  id: stringcreatedAt? :numberupdatedAt? :numberconfirmedAt? :number
}
Copy the code

An optimistically updated Action has a life-cycle similar to that of a Promise. After it is created, it enters a pending state and is either confirmed or cancelled. The most basic example is to create an Action and add it to the queue while sending the request, and then apply the actions in the queue to the current state to get the new state.

If the Action is cancelled, it is removed from the queue and the new State is recalculated, thus completing the client-side undo operation. On the server side, the data doesn’t change at all.

An Action can be merged into an identified State and removed from the queue only if it is confirmed.

In particular, optimistically updated actions can be updated. Because an optimistic update may not only contain operations on one object, it may also have a series of operations associated with data. This brings us to ID management.

ID management

In a traditional CRUD structure, the job of generating ids is usually left to the server, and the client just reads them. But in an optimistic update, the client needs these ids before the interface returns, and cannot wait for the server to generate them, so the client must generate these ids itself. Therefore, the client can only generate a temporary tempID, occupy the hole, and wait for the real ID generated by the server to replace it.

For scenarios involving only a single object, the ID replacement is scheduled for Confirm. However, if you need to create several objects associated with one object, the problem becomes more complicated. In order to render the structure to the user as quickly as possible, all tempids are created together, including the fields used for the association, which also fetch the value of the tempID. However, in order to achieve real data association, we still need to get the real ID returned by the result of the previous step before proceeding to the next step.

Take, for example, a requirement I recently worked on: a discussion system that supports hand-drawn circles.

Since the system needs to support the search of circles (such as finding all “red” + “rectangular” circles), the data of circles must be modeled and stored separately, and the discussion to which they belong must be recorded.

When sending a discussion of striped circles, the client first creates a tempID for each of them and associates them with the tempID, creating at least two actions.

const actionIdD = createAction({
  type: 'CREATE_DISCUSSION',
  payload: {
    id: 'temp_id_d'}})const actionIdA = createAction({
  type: 'CREATE_ANNOTATION',
  payload: {
    id: 'temp_id_a',
    discussionId: 'temp_id_d'}})// Update Queue
[
  {
    id: 'action_id_d'.type: 'CREATE_DISCUSSION',
    payload: {
      id: 'temp_id_d'
    }
  },
  {
    id: 'action_id_a'.type: 'CREATE_ANNOTATION',
    payload: {
      id: 'temp_id_a',
      discussionId: 'temp_id_d'}}]Copy the code

Since the circle needs to record the comments to which it belongs, it is required that the corresponding discussion must already exist when the circle data is created. Therefore, when requesting the interface, POST/Discussion needs to be initiated first to replace the tempID referenced by the client data with the real discussion ID in the returned result.

Note that you need to operate on multiple actions. The data in question has been created at this point, so the corresponding Action can be directly confirmed. However, the circle creation is not yet complete, so we can only update it first.

Here we set the Confirm operation to be accompanied by an update, or you can set it to be a more pure operation and do an additional update.

confirmAction(actionIdD, {
  id: 'id_d'
})

updateAction(actionIdA, {
  discussionId: 'id_d'
})

// Update Queue
[
  {
    id: 'action_id_d'.type: 'CREATE_DISCUSSION',
    payload: {
      id: 'id_d'
    }
  },
  {
    id: 'action_id_a'.type: 'CREATE_ANNOTATION',
    payload: {
      id: 'temp_id_a',
      discussionId: 'id_d'}}]Copy the code

Now we can finally initiate POST /annotation to create the circle data and update the client’s circle data again with the real data returned. At this point, the data of the circle is also created, so you can confirm it directly.

confirmAction(actionIdA, {
  id: 'id_a'
})

// Update Queue
[
  {
    id: 'action_id_d'.type: 'CREATE_DISCUSSION',
    payload: {
      id: 'id_d'
    }
  },
  {
    id: 'action_id_a'.type: 'CREATE_ANNOTATION',
    payload: {
      // highlight-next-line
      id: 'id_a',
      discussionId: 'id_d'}}]Copy the code

Timing problems

Another important concern for optimistic updates is “timing.”

A common mistake many people make when they are first exposed to “optimistic updates” is that actions can be merged into “determined states” as soon as they are confirmed. However, it is possible to have a sequence between actions, and if you do it out of sequence, you may get unexpected results.

Let’s do it again.

Assume that the initial state is A, change it to B, and then change it to C before operation B is confirmed. We expect to end up with a C. In a synchronous system, this is the inevitable result. However, if the system is “optimistic update”, it is uncertain which of the two operations is confirmed first. If the confirm is combined immediately, the request of B may be sent first, but returned later than THAT of C. As a result, the status changes to C first, then TO B, and finally to B.

So we must intervene in this step so that the actions can only be merged in the order they were created. Implementation-specific, only the Action at the top of the queue can be merged, and no subsequent actions can be merged until it is merged.

Better yet, we can set up a timeout mechanism to prevent one Action from going unanswered for too long, blocking the merging of subsequent actions and causing the queue to grow indefinitely, affecting performance.

summary

As the most direct display of the product, the client experience directly affects the user’s impression of the product, and its importance is self-evident. “Optimistic updates” are a great way to improve the user experience, and while the technical implementation is a little tricky, the results are welcome. Under the premise that the current network environment is generally good, we hope to see more products can apply it in the appropriate scene, reduce those “stuck” moments, so that the use of products to a higher level.