Solutional
ET EN

How to Implement Retry Logic in JavaScript_

Learn why retrying the same Promise does not work, how to test retry behavior, and how to implement reliable JavaScript retry logic with exponential backoff.

Transient failures are common when software calls external APIs. A network connection may drop, a service may be temporarily unavailable, or a request may time out. Retrying can make an integration more resilient, but only when the retry logic creates a genuinely new attempt.

In languages where application code is mostly synchronous, a basic retry loop can be simple. Here is a minimal example in the Ruby programming language:

def get(url)
  http_client.get(url)
rescue
  sleep 1
  retry
end

The method performs a GET request and retries after a short delay when an error occurs. Production code needs more safeguards: a maximum number of attempts, error logging, and a backoff strategy that avoids overwhelming the remote service or accidentally contributing to a denial-of-service attack.

Why JavaScript Retry Logic Is Different

JavaScript adds another consideration because API calls are usually asynchronous. We encountered the following retry implementation in a client's project:

const legacyRetry = async (promise, retryCount, timeout, increaseTimeoutBy) => {
    try {
        return await new Promise(async (resolve, reject) => {
            setTimeout(async () => {
                reject('Timeout is reached!')
            }, timeout)
            try {
                resolve(await promise)
            } catch (e) {
                reject(e)
            }
        })
    } catch (err) {
        if (retryCount < 1) {
            throw err
        }
        const newTimeout = (timeout += increaseTimeoutBy)
        console.log('Retrying with timeout: ', newTimeout)
        return await legacyRetry(promise, retryCount - 1, newTimeout, increaseTimeoutBy)
    }
}

It was called like this:

const result = await legacyRetry(httpClient.get(url), 3, 360000, 80000);
console.info(result);

At first glance, the function appears sophisticated. It creates a timeout, catches errors, increases the timeout value, and calls itself recursively. Yet it never performs a true retry.

The problem is visible in the function signature: legacyRetry receives a Promise instance. A Promise can settle only once. After that Promise has been rejected, awaiting it again does not repeat the underlying HTTP request; it simply returns the same rejection.

A retry function therefore needs a way to start the operation again. Passing an already-created Promise does not provide that capability.

Building a Small Test for Retry Behavior

The following helpers let us reproduce the problem without calling a real API:

const request = (failuresCount) => {
    return () => {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                if (failuresCount-- > 0) {
                    reject("error response")
                } else {
                    resolve("success response")
                }
            })
        })
    }
}

const fail = () => {
    throw new Error("Expected to fail")
}

request(failuresCount) returns a function that fails the specified number of times and then succeeds. The setTimeout introduces asynchronous behavior, while fail() makes a test fail explicitly when an expected error does not occur.

First, we can verify that the request helper behaves as intended:

const testFailingRequest = async () => {
    const failingRequest = request(2)

    console.log("First try")
    try {
        await failingRequest()
        fail()
    } catch (e) {
        console.log("First failure:", e)
    }

    console.log("Second try")
    try {
        await failingRequest()
        fail()
    } catch (e) {
        console.log("Second failure:", e)
    }

    console.log("Third try")
    let result = await failingRequest()
    console.log("Third try result:", result)
}

testFailingRequest()

The first two calls fail and the third succeeds:

First try
First failure: error response
Second try
Second failure: error response
Third try
Third try result: success response

Now we can test the original legacyRetry function:

const testLegacyRetry = async () => {
    try {
        await legacyRetry(request(3)(), 2, 1, 1)
        fail()
    } catch (e) {
        console.log("Legacy retry result:", e)
    }

    try {
        await legacyRetry(request(2)(), 3, 1, 1)
        fail()
    } catch (e) {
        console.log("Legacy retry result:", e)
    }

    const response = await legacyRetry(request(0)(), 2, 1, 1)
    console.log("Legacy retry result:", response)
}

testLegacyRetry()

Its output is:

Retrying with timeout:  2
Retrying with timeout:  3
Legacy retry result: error response
Retrying with timeout:  2
Retrying with timeout:  3
Retrying with timeout:  4
Legacy retry result: error response
Legacy retry result: success response

The log messages suggest that retries occurred, but the successful result appears only when the original request did not fail. The function repeatedly awaited the same rejected Promise instead of creating a new request.

What Was Wrong With the Original Retry Function?

The implementation had two fundamental problems:

  1. It accepted a Promise instead of a function that could create a new Promise for every attempt.
  2. It had not been tested with both successful and failing scenarios.

The timeout behavior was also misleading. The code increased the failure timeout, but it did not introduce a meaningful delay between separate requests because there were no separate requests.

A small automated test would have exposed the defect immediately. Even two manual checks would have been enough: one request that succeeds after a retry and one that exhausts all retries.

Should You Use an Existing Retry Package?

Retry logic is broadly useful, so using a maintained package can be a sensible choice. We reviewed several implementations and examples ([1], [2], [3], [4], and [5]).

For our use case, some options were more complex than necessary, while others mixed retry concerns into the operation being retried. We wanted the retriable function to remain unaware of the retry mechanism. That follows the separation of concerns principle and allows the same function to be executed with or without retries.

A Working JavaScript Retry Function

Instead of modifying the old implementation, we wrote a smaller version:

const retry = async (fn, maxAttempts) => {
  const execute = async (attempt) => {
    try {
        return await fn()
    } catch (err) {
        if (attempt <= maxAttempts) {
            const nextAttempt = attempt + 1
            const delayInSeconds = Math.max(Math.min(Math.pow(2, nextAttempt)
              + randInt(-nextAttempt, nextAttempt), 600), 1)
            console.error(`Retrying after ${delayInSeconds} seconds due to:`, err)
            return delay(() => execute(nextAttempt), delayInSeconds * 1000)
        } else {
            throw err
        }
    }
  }
  return execute(1)
}

const delay = (fn, ms) => new Promise((resolve) => setTimeout(() => resolve(fn()), ms))

const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1) + min)

The key change is the first argument. retry receives a function, fn, rather than an existing Promise. Every call to fn() creates a new Promise and starts a new attempt.

The function also uses exponential backoff with a small random variation. After each failure, it waits longer before trying again, up to a maximum delay. This gives an overloaded or temporarily unavailable service time to recover and reduces the chance of many clients retrying at exactly the same moment.

Testing the Working Retry Implementation

The same request helper can test both failure and recovery:

const testRetry = async () => {
    try {
        await retry(request(3), 2)
        fail()
    } catch (e) {
        console.log("Failing retry result:", e)
    }

    const response = await retry(request(5), 10);
    console.log("Successful retry result:", response)
}

testRetry()

A sample run produces output like this:

Retrying after 3 seconds due to: error response
Retrying after 5 seconds due to: error response
Failing retry result: error response
Retrying after 6 seconds due to: error response
Retrying after 7 seconds due to: error response
Retrying after 17 seconds due to: error response
Retrying after 36 seconds due to: error response
Retrying after 64 seconds due to: error response
Successful retry result: success response

The exact delay values vary because the backoff includes a random component. The important behavior is consistent: the first scenario stops after the allowed attempts, while the second eventually succeeds because every retry invokes the request function again.

JavaScript Retry Logic: Key Lessons

Reliable retry logic requires more than recursively awaiting a Promise. The implementation must create a new operation for every attempt, limit the number of retries, wait between attempts, and be tested against both success and failure paths.

In production systems, also consider which errors are actually retriable. A temporary network failure may justify another attempt; invalid input or an authentication failure usually does not. Add structured logging and monitoring so repeated retries are visible rather than silently hiding a failing dependency.

The example above demonstrates the core pattern: pass a function that creates a Promise, not the Promise itself.

[ Read other articles ]

Start with the problem.

Tell us what must work better, what is blocking progress or what you need to build. We will give you a direct, technically grounded assessment of the best way forward.