Almost every modern web application somehow interacts with a backend - be it loading data, doing background sync, submitting a form, or publishing the metrics. Making API requests is not an easy task - we have to consider multiple outcomes and handle them properly. Otherwise, we might end up with confused users and decreased conversion. Although the stakes are high, it is still very likely to encounter an application designed with only a happy path scenario in mind. The question is - how can we improve it?

Make the request state visible

Back in the old days submitting a form would result in a full page reload. Until the page was ready, there was a clear sign that something was happening. If something went wrong typically there was an unstyled generic error message.

This approach served its purpose very well - it was easy to tell that the page was still loading and it was easy to tell when there was an error. Then AJAX became popular, bringing with its benefits also certain drawbacks - it was up to the programmer to handle loading and error state indicators, which were often omitted. To prevent user’s confusion, you should always remember about clearly presenting the request state to the user.

Retry failed requests

As mentioned above, errors do happen sometimes. Usually, users are faced with a message and an option to retry the request. This approach is far better than failing silently and not informing the user, but still can lead to abandoned actions. Maybe we can do better than that?

What if instead of asking the user to retry the failed request, we could do it automatically? There is another follow-up question: is every request worth retrying? Imagine a server responding with status code 403 (forbidden), 422 (unprocessable entity), or 4xx in general. These are the client’s faults and usually retrying those requests will yield the same result. Now let’s consider 5xx status codes, which are often caused by temporary database unavailability or server resource exhaustion. Especially in multi-server environments, chances are that the next request will be rerouted to a healthy instance, resulting in a successful response.

On the other hand, in case of increased traffic, repeating failed requests instantly could possibly make things worse. In order to prevent that, it is a good practice to introduce an exponential delay between consecutive retry attempts. Another solution, more common in inter-service communication, is a circuit breaker mechanism which prevents further requests until service becomes available.

Also, keep in mind that network conditions might not be stable, particularly on mobile devices. If there is no connection, instead of making pointless API calls you could queue the requests and observe online/offline events.

Finally, not every request is safe to retry. Sometimes when you receive 5xx, there is no guarantee that it has not been processed. Imagine retrying a request to make a money transfer from one account to another - handling it twice would be a disaster! In order to prevent these mistakes from happening, you have to make sure your API is idempotent. This is usually achieved by using adequate HTTP methods (like PUT) or passing an additional identifier with the request.

Do not wait forever

Have you ever wondered how long it takes before an API call times out due to no response from the server? If not, I have bad news for you - the default timeout value in XMLHttpRequest is 0, which basically means the browser will wait forever. With fetch there is no parameter responsible for timeouts - it relies on browser defaults (which is 300 seconds for Chrome and Firefox.

The good news is that you can actually implement timeout functionality using AbortController:

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000); // 3s timeout

fetch("/api/something", { signal: controller.signal })
  .then(response => {
    clearTimeout(timeout);
    // process the response
  });

Another useful application of AbortController is cancelling straggler requests (requests still in progress at a point when the response is no longer needed). This is particularly useful in libraries like React where receiving a response after unmounting a component results in an error:

useEffect(() => {
  const controller = new AbortController();

  fetch("/api/something", { signal: controller.signal })
    .then(response => {
      // process the response
    });

  return () => controller.abort(); // cancel the request when un-mounting the component
}, []);

Be optimistic

As funny as it may sound, there is an actual pattern called “optimistic UI”. The idea behind it is very straightforward: given that most of the time an API request will result in a successful response, we can skip the “loading” part and go straight to a result stage. In the unlikely event of failure, we can still rollback our changes and inform the user about the error.

Let’s consider a popular example of a counter (eg. Facebook/Twitter like button). For the sake of simplicity I will skip the actual request and return a promise after 1 second so that approximately 25% of requests will fail:

Counter value: <span id="counter">0</span> <button id="button">Increase</button>

<script>
  let counter = 0;

  const updateView = () => document.querySelector("#counter").innerText = counter;

  const makeRequest = () => new Promise((resolve, reject) => {
    const outcome = Math.random() > 0.25 ? resolve : reject;
    setTimeout(outcome, 1000);
  });

  document.querySelector("#button").addEventListener("click", async () => {
    counter++;
    updateView();

    try {
      await makeRequest();
    } catch (e) {
      counter--;
      updateView();
    }
  });
</script>

By simply assuming the positive outcome we drastically reduce the amount of time spent on the interactions, making the whole experience more swift. However, due to the risk of so-called false positives this pattern should not be applied to critical actions (for example, making a reservation). If you are further interested I recommend reading a more comprehensive article about optimistic UI.

Summary

In this article, I presented the basics of requests handling. Some of these ideas (like retries and timeouts) also apply to the backend service-to-service communication. Additionally, there are other interesting techniques which I encourage you to study (eg. preconnecting, batching). In the end, in my opinion, everything that improves UX is worth a try.