The test calls the operation, gets an error, and waits through the full backoff before seeing the second attempt. In CI, the case becomes slow. When someone replaces the wait with a fake timer, another problem appears: the clock advances, but the assertion still cannot see the next retry.

To test retry logic in JavaScript without waiting, prove three separate boundaries: the function was called again, the correct delay was scheduled, and the Promise settled before the assertion. A fake timer controls the clock. It does not replace the async queue or decide whether repeating an external effect is safe.

This guide uses Vitest in its examples and compares the ideas with Jest and node:test. The snippets are illustrative and were not run in this repository. They show the order your test suite should verify.

Diagram of a retry test with a controlled clock, Promise queue, and assertion.

Short answer

  • Pass a function to the retry helper, not a Promise that has already started.
  • Install the fake clock before starting the operation.
  • Advance the timer and let the Promise queue settle before counting the next attempt.
  • Restore the clock and test the final limit and effects that must not be duplicated.

What does a retry test need to prove?

A useful test does not check only that the operation ended with "ok". It proves which error can be retried, how many attempts occur, which delay sits between attempts, and which error reaches the caller when the limit ends. The final result is one part of the contract, not the whole contract.

If the operation calls an API, queue, or database, add one more question: does a second call repeat a read, or does it repeat an effect? The overview of software testing types helps separate the unit proof of the retry loop from the integration test that checks the real service.

A small contract can be written like this:

Contract part What to observe
First attempt The function receives the expected input.
Recoverable failure The error schedules a delay instead of ending early.
Next attempt The function is called again after the delay.
Terminal failure The limit prevents another call and preserves the error.
External effect Repeating the operation does not create a second charge, message, or write.

This list also prevents a misleading test. If you only await the final Promise, a retry that attempts too many times can stay green. If you only count calls, code that retries immediately, without respecting backoff, also passes.

Why does retrying a ready Promise create a false test?

The retry helper needs a function that creates a new attempt. A Promise represents an execution that has already started. Saving that Promise and awaiting it again waits for the same result; it does not call the operation again.

This first shape is easy to write and hard to test correctly:

const response = fetchData();
return retry(response, { maxAttempts: 3 });

After fetchData() fails, retry cannot start another request. It received the rejected object that already exists. The shape that preserves a new attempt is this:

return retry(() => fetchData(), { maxAttempts: 3 });

Make the difference visible in the test. Configure the function to reject on the first call and resolve on the second. Then check both the call count and the delay. If the code accepts a ready Promise, the second call cannot happen. That is a design error, not something fake timers can repair.

How do you test retry with Vitest fake timers?

The example needs an injectable delay and an operation that can return a different result on each call. The Vitest timers guide, retrieved on 2026-09-01, documents vi.useFakeTimers() and the functions that advance the clock without waiting for real time.

type RetryOptions = {
  maxAttempts: number;
  delayMs: number;
};

export async function retry<T>(
  operation: () => Promise<T>,
  options: RetryOptions,
): Promise<T> {
  let lastError: unknown;

  for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
    try {
      return await operation();
    } catch (error) {
      lastError = error;

      if (attempt === options.maxAttempts) {
        break;
      }

      await new Promise<void>((resolve) => {
        setTimeout(resolve, options.delayMs * attempt);
      });
    }
  }

  throw lastError;
}

The test starts the operation without immediately awaiting the final result. That lets it verify that the first call failed and the second attempt still depends on the timer:

import { afterEach, describe, expect, it, vi } from "vitest";

describe("retry", () => {
  afterEach(() => {
    vi.useRealTimers();
  });

  it("advances backoff before the next attempt", async () => {
    vi.useFakeTimers();

    const operation = vi
      .fn<() => Promise<string>>()
      .mockRejectedValueOnce(new Error("temporary"))
      .mockResolvedValueOnce("ok");

    const result = retry(operation, { maxAttempts: 3, delayMs: 1_000 });

    await Promise.resolve();
    expect(operation).toHaveBeenCalledTimes(1);

    await vi.advanceTimersByTimeAsync(1_000);

    await expect(result).resolves.toBe("ok");
    expect(operation).toHaveBeenCalledTimes(2);
  });
});

The 1_000 value belongs to the fixture. It is not a universal backoff recommendation. The test proves that the second attempt does not happen before the configured delay. Production code may use exponential backoff, jitter, or a shared deadline. The test should mirror that policy instead of hiding the difference behind runAllTimers().

How do you advance the timer without losing the Promise?

The common failure is advancing the clock and asserting on the next line. The timer callback may start async work, but the result becomes observable only after the Promise queue is processed. Use the runner's async timer API when it exists.

In Vitest, vi.advanceTimersByTimeAsync() advances timers and gives async work room to settle. The Jest Timer Mocks guide, retrieved on 2026-09-01, documents jest.advanceTimersByTime() and APIs for running pending timers. In node:test, the Node.js test runner guide, retrieved on 2026-09-01, documents context.mock.timers.tick() and reset().

Runner Test control Main care point
Vitest vi.useFakeTimers() and vi.advanceTimersByTimeAsync(ms) Restore with vi.useRealTimers().
Jest jest.useFakeTimers() and the available async variant Call jest.useRealTimers() in cleanup.
node:test context.mock.timers.enable() and tick(ms) Confirm the Node version used in CI.

If the retry schedules another timer from inside its callback, advance one interval at a time when you want to inspect each attempt. runAllTimers() fits a finite, known set of timers, but it is risky for permanent intervals or unbounded loops. Jest's documentation recommends pending-timer methods when a callback schedules another timer.

The article about separating dates from timers in a test covers the same distinction in more detail. The observed date, the timer that schedules work, and the Promise that delivers the result can be different dependencies.

What should you test when every attempt fails?

The success-after-one-error case covers only one transition. Add a case that always fails and confirm that retry stops at the limit, returns the right error, and does not schedule a fourth attempt. If the implementation swallows the error or resets the counter on every call, this case exposes it.

it("stops at the limit and preserves the final error", async () => {
  vi.useFakeTimers();

  const error = new Error("permanent");
  const operation = vi.fn<() => Promise<never>>().mockRejectedValue(error);
  const result = retry(operation, { maxAttempts: 3, delayMs: 100 });

  await vi.runAllTimersAsync();

  await expect(result).rejects.toBe(error);
  expect(operation).toHaveBeenCalledTimes(3);
});

If the runner has no runAllTimersAsync, advance the expected delays and await the Promise after each step. The test is longer, but it shows which event releases each attempt. For a loop that can grow forever, do not use a function that tries to empty every timer. Set a limit in the code and make the test stop at that limit.

How do you test retry without duplicating external effects?

A read can often run again after a timeout. A function that sends a message, records a payment, or changes inventory needs another proof: a timeout tells you that the response did not arrive, not that the effect did not happen. The deterministic test should use a fake or controlled adapter to simulate the ambiguous state.

That boundary sits outside fake timers. Test separately whether the execution keeps an idempotency key, checks known state, or stops before repeating the effect. For tool calls, validate the boundary before retrying shows why an error needs enough state for a decision.

A reliable retry test measures two different things: the scheduler repeated the attempt at the right time, and the domain allowed that operation to repeat. Combining both in a test that calls a real API often creates an ambiguous result. The timer can be correct while protection against duplicate effects is still missing.

How do you verify and clean up the test?

Before putting the case in CI, review it in this order:

  1. Pass a function to retry and confirm that each call creates an attempt.
  2. Install fake timers before starting the operation or importing a module that captures the clock.
  3. Confirm the call count before and after each advance.
  4. Use the runner's async API when the callback starts a Promise.
  5. Test success, terminal failure, and the delay that must not be skipped.
  6. Restore real timers even when an assertion fails.
  7. Separate the scheduler proof from idempotency and API contract tests.

Run the case alone and then run the full suite. If it fails only in the suite, look for a leaked clock, pending interval, or shared state. The article about avoiding shared state in CI covers the same isolation class in browser tests.

Testing retry without waiting does not mean speeding up any test with one button. It means controlling the delay that belongs to the contract and waiting for the work that belongs to the Promise. When those boundaries appear in the test, a green suite becomes more informative: it tells you how many attempts happened, when they happened, and which result can still be repeated safely.

Sources consulted