Optimistic updates with concurrency control
At First Resonance we’re building software for complex manufacturers to deliver hardware with confidence.
Optimistic updates is a common pattern to improve the user experience by allowing the UI to behave as if a request to your server has successfully completed before receiving a response. For example, as I’m typing this post the user interface keeps updating the text on the screen without delays, while at the same time sending requests to the API to save the content. This pattern is well understood, however it gets more complex when performing these updates safely when multiple people are making updates to the same resource, i.e. under concurrency control.
When designing an API where multiple users can update a single shared resource, one common challenge is how to ensure that a client submit updates to the most up to date version and how to perform those updates without overwriting someone else’s changes. There are different concurrency control schemes to address this challenge and, as usual, they come with different tradeoffs. In this post, I’ll focus on how these strategies work in the context of optimistic UI updates and describe the solution we adopted at First Resonance.
Do nothing
The simplest strategy is to do nothing. The last update wins, even if it was done on an outdated version of the resource. Although this strategy can be used with an optimistically updated UI, it can result in 2 users having different data on their UI and overwriting each others’ changes.
Making atomic changes, i.e. changing one attribute at a time, makes the “lost update” problem less likely to occur, but still possible.
Pessimistic lock
In a pessimistic locking strategy, the client locks a record for exclusive use until it’s done making updates with the resource. This is totally compatible with optimistic updates because there can only be one user using the resource at a time so there’s no risk of overwriting someone else’s changes.
The tradeoff here is that the resource has to be explicitly locked & unlocked, which might lead to organizational inefficiencies if someone forgets to unlock a resource. Who wants to visit a library just to be turned down because the book was checked out by someone else? I guess that’s why people like Kindle.
Optimistic lock
When following the optimistic lock strategy, a request is successful only if the resource hasn’t been modified since the client last fetched the resource. This works by changing the version of the record on every update and having the client send the current version of the record it is applying updates to. If the version of the record in the server doesn’t match the version that the client is sending then the update request will fail. This is such a common scheme that HTTP provides some built-in mechanisms for implementing optimistic lock using the If-Match header and ETags.
In the context of optimistic updates, this strategy can be problematic. When doing optimistic updates, requests to the server are performed asynchronously and sometimes a new request is sent before the previous has completed, which results in an ETag mismatch error.
As can be seen on the picture above, the second request still carries the previous ETag
value, however the server’s record has already been updated and responded with a new ETag
value. Although this issue can be attenuated by increasing the debounce timeout (usually used with optimistic updates when updating text), a more reliable measure is needed.
Other approaches..
So how do we deal with concurrency control on optimistic updates when none of the above strategies are an option for your use case?
After doing a bit of googling, as you do, I wasn’t able to find much about other strategies for dealing with concurrency control while doing optimistic updates other than complex real-time collaborative editing solutions such as OT and CRDT. These approaches are great but require complex logic and persistent connections over WebSockets, the concepts do not extend to a request-response context.
Conditional optimistic lock
After looking at all other approaches, we decided to go for a custom solution by extending optimistic lock (which we already had implemented) to work for us by looking closely at what works and what doesn’t work:
- Optimistic lock works great for us to avoid conflicts between different users.
- However, when requests are coming from the same client instance, we’d like to avoid
ETag
mismatch errors when doing continuous optimistic updates
With this in mind we can easily extend optimistic lock by adding an extra parameter to conditionally skip checks for the etag if the request is coming from the same client instance that last updated the record. An example of this approach is pictured in the diagram below:
We’re calling this extra parameter session_id.
The session_id
is a unique identifier that gets initialized when the SPA (single page application) loads and is sent with every request along with the Etag
.
The server will persist the session_id
on every update to a record and will conditionally check the ETag
if the request’s session_id
doesn’t match the last saved session_id
.
This solution makes sure that the client instance that made that last update to the record has the most up to date information since the ETag
had to be verified in order to initially update the record due to the session_id
not matching the request’s initially (Step 3 in the diagram above). After the first request, the record has been updated with the client instance’s session_id
and subsequent request will skip etag verification, allowing for asynchronous optimistic updates without Etag
mismatch errors.
This can be further enhanced by having a time window in which you assume the client that last updated the record has the latest information. This is exemplified in the code snippet below:
Summary
- Conventional optimistic lock doesn’t work well with optimistic updates
- Extending the optimistic lock (etag) approach with a
session_id
is a simple and reliable alternative to handle concurrency control compatible with continuous optimistic UI updates.
We’d love to hear from the community: What strategies are you using for concurrency control? Leave a comment!