Balancing Responsiveness and State Consistency in SPA

Balancing Responsiveness and State Consistency in SPA

One of the interesting technical challenges to solve when building a modern web application is to achieve acceptable responsiveness for a UI depending on sometimes sluggish API endpoints.

User's perception of an application as slow is all about latency, reactions, wait times and feedback. Delays, freezes, feedback lags, unresponsiveness — all that makes your product feel heavy. There is a great article summarizing on what slow means in the world of software.

On the API side, the definition of slow may vary, but we assume something above 100 milliseconds for a single request roundtrip. Optimizing and squeezing response times is not a trivial task even for simple methods without database involved. There are many layers of added latency involved: network delays, secure sockets and encryption, reverse proxy, balancers, database, cache hits etc. It's a separate wide topic and a great field for experimentation.

Modern single page applications (SPA) are depending on API latency and the more desktop-like experience you want, the faster API responses you need. But as with any client-server application, you should expect delays, latency and lost connections. Slow and broken API responses is a core assumption that you should take when designing a web application. There is where some neat SPA patterns come in handy.

Let's take a simple example. Imagine we're building a simple todo SPA where an user can add an item and then change its status to completed.

There are many possible UX scenarios, but for our example, we assume that pressing a plus button adds a new item, which we can then edit or mark as completed. So pressing a button instantly creates an item and requires no other actions from a user. For our example application, adding an item requires a single POST request to an API. A naïve approach would be to issue a request, wait for the promise to fulfill and then update the view (or state, if using redux-like architecture). No surprise that this instantly causes a delay equal to an API request round trip time. A little excuse to that delay can be a spinner or another work-in-progress indicator.

Is there a way to avoid such a frustrating experience? Sure. A widely used approach for SPA application is to use dummy objects, which are instantly created and later replaced with real objects with ids that come from a back-end.

This little trick completely solves a latency problem. There will be no user-noticeable delays between actions and outcomes. Trade-offs are obvious: when something goes wrong, your newly created item will disappear. Or, worse, you'll continue interacting with it not expecting nothing would be saved after you close a tab. But, let's assume it's an exceptional (and thus rare) situation. In case of the failed backend method, you can always present a gentle notice to your user warning him that something has gone wrong and your item won't be saved. Additionally, you can silently retry one or more times before panicking. But let's go to a more complicated example:

This time, a new UI interaction happens in the middle, changing an item attribute while API request promise is being resolved and haven't replaced a dummy object with a real one. Simply ignoring this case would lead to a broken UI, confuse a user and can result in a bad input.

I am not aware of reliable solutions for the above problem that do not compromise on some aspects of UX (responsiveness, perception of an application as a fast, freezes, blocks) or reliability (accidentally reverting or losing changes). That's where we should decide on a balance of responsiveness and state consistency. For the example above, we can simply decide to block any interaction with a dummy object until it is replaced with a real one. Or build a more complex state mutation logic with pending states, queues etc.

Things become more complicated when interaction involves multiple objects. Experimenting with the previous example, what if we want to change the order of the items, swapping Item B and still dummy Item C:

This time, replacing a dummy item override attribute change and makes the application state invalid (and inconsistent with back-end). Then what to do to resolve conflicting state changes? First, not to apply state mutations that lead to an inconsistent or broken state. In the previous example, we can compare field by field and ignore weight value change. Second, we can keep a record of pending changes and apply only attributes that are not subject to change. That is a more complex approach than the first one but is more versatile and reliable.

There should be more established patterns for solving responsiveness issues in SPA and I'll try to cover them when anything interesting comes in sight.

Finally, I haven't touched a concurrent scenario with multiple users which is essentially quite a different topic and involves distributed systems stuff like consensus algorithms and so on.