Retrying and Exponential Backoff with Promises

Suggested Reading: Sequential and Parallel Asynchronous Functions

When you don't have an interface for knowing when a remote resource is available, an option to consider is to use exponential backoff rather than to poll that resource until you get a response.

Set Up

In this scenario, let's mimic the behaviour of a browser and a server. Let's say the server has an abysmal failure rate of 80%.

const randomlyFail = (resolve, reject) =>
Math.random() < 0.8 ? reject() : resolve();

const apiCall = () =>
new Promise((...args) => setTimeout(() => randomlyFail(...args), 1000));

The apiCall function mimics the behaviour of calling an endpoint on a server.

Retrying

When the apiCall is rejected, getResource is called again immediately.

const getResource = () => apiCall().catch(getResource);

With a Delay

If a server is already failing, it may not be wise to overwhelm it with requests. Adding a delay to the system can be a provisional solution to the problem. If possible, it would be better to investigate the cause of the server failing.

const delay = () => new Promise(resolve => setTimeout(resolve, 1000));

const getResource = () =>
apiCall().catch(() => delay().then(() => getResource()));

Exponential Backoff

The severity of a server's failure is sometimes unknown. A server could have had a temporary error or could be offline completely. It can be beneficial to increase the retry delay with every attempt.

The retry count is passed to the delay function and is used to set the setTimeout delay.

const delay = retryCount =>
new Promise(resolve => setTimeout(resolve, 10 ** retryCount));

const getResource = (retryCount = 0) =>
apiCall().catch(() => delay(retryCount).then(() => getResource(retryCount + 1)));

In this case $10^n$ was used as the timeout where $n$ is the retry count. In other words the first 5 retries will have the following delays: $[1 ms, 10 ms, 100 ms, 1 s, 10 s]$.

Using async/await and Adding a Retry Limit

The above can also be written in a more intelligible form using async/await. A retry limit was also added to restrict the maximum wait time for the getResource function. Since we now have a retry limit, we should have a way to propagate the error, so a lastError parameter was added.

const getResource = async (retryCount = 0, lastError = null) => {
if (retryCount > 5) throw new Error(lastError);
try {
return apiCall();
} catch (e) {
await delay(retryCount);
return getResource(retryCount + 1, e);
}
};

Conclusion

Exponential backoff is a useful technique in dealing with unpredictable API responses. Each project will have its own set of requirements and constraints, and there may be circumstances where there are better solutions. My hope is that, if exponential backoff is the right solution, you'll know how to implement it.