Skip to the code
cf.

Race that aborts the losers

Write raceWithAbort(tasks). tasks is an array of functions. Each takes an AbortSignal, the way fetch does, and returns a promise or a plain value. Call every task right away, in order, each with a signal of its own.

Return a promise that settles like the first task to settle: it fulfills with that task's value, or rejects with its error. A task that throws counts as one that rejects.

As soon as the first task settles, abort every other task's signal, so those tasks can stop their work. Nothing is aborted before that, and the winner's signal is never aborted, even when the other tasks reject after you've aborted them.

const quote = await raceWithAbort([
  (signal) => fetch("https://eu.example.com/quote", { signal }),
  (signal) => fetch("https://us.example.com/quote", { signal }),
]);
// the faster server's response; the other request is aborted
 
const wait = (ms, value) => (signal) =>
  new Promise((resolve, reject) => {
    const timer = setTimeout(resolve, ms, value);
    signal.addEventListener("abort", () => {
      clearTimeout(timer);
      reject(signal.reason);
    });
  });
 
await raceWithAbort([wait(300, "slow"), wait(100, "fast")]);
// "fast", after 100 ms. The "slow" task's signal aborts then, so it clears its timer and rejects.

What the tests check

  1. calls every task right away, each with its own AbortSignal
    const first = spy(() => new Promise(() => {}));
    const second = spy(() => new Promise(() => {}));
    raceWithAbort([first, second]);
    expect(first).toHaveBeenCalledTimes(1);
    expect(second).toHaveBeenCalledTimes(1);
    const a = first.calls[0].args[0];
    const b = second.calls[0].args[0];
    expect(a).toBeInstanceOf(AbortSignal);
    expect(b).toBeInstanceOf(AbortSignal);
    expect(a).not.toBe(b);
    expect(a.aborted).toBe(false);
    expect(b.aborted).toBe(false);
    
  2. fulfills with the first value to arrive
    const clock = useFakeTimers();
    const after = (ms, value) => () => new Promise((resolve) => setTimeout(resolve, ms, value));
    let result;
    raceWithAbort([after(300, "slow"), after(100, "fast"), after(200, "medium")]).then((value) => {
      result = value;
    });
    await clock.tickAsync(100);
    expect(result).toBe("fast");
    
  3. rejects when the first task to settle rejects
    const clock = useFakeTimers();
    const error = new Error("server error");
    let reason;
    raceWithAbort([
      () => new Promise((resolve) => setTimeout(resolve, 200, "data")),
      () => new Promise((_, reject) => setTimeout(reject, 100, error)),
    ]).catch((e) => {
      reason = e;
    });
    await clock.tickAsync(100);
    expect(reason).toBe(error);
    
  4. aborts the other tasks once one fulfills, and not before
    const clock = useFakeTimers();
    const signals = [];
    const after = (ms, value) => (signal) => {
      signals.push(signal);
      return new Promise((resolve) => setTimeout(resolve, ms, value));
    };
    raceWithAbort([after(300, "slow"), after(100, "fast"), after(200, "medium")]);
    await clock.tickAsync(99);
    expect(signals.map((signal) => signal.aborted)).toEqual([false, false, false]);
    await clock.tickAsync(1);
    expect(signals[0].aborted).toBe(true);
    expect(signals[2].aborted).toBe(true);
    
  5. aborts the other tasks when the first one rejects, too
    const clock = useFakeTimers();
    const slow = spy(() => new Promise(() => {}));
    const failing = () => new Promise((_, reject) => setTimeout(reject, 100, new Error("offline")));
    raceWithAbort([slow, failing]).catch(() => {}); // it rejects; this test only checks the abort
    await clock.tickAsync(100);
    expect(slow.calls[0].args[0].aborted).toBe(true);
    
  6. never aborts the winner, even when the losers reject on abort
    const clock = useFakeTimers();
    const wait = (ms, value) => (signal) =>
      new Promise((resolve, reject) => {
        const timer = setTimeout(resolve, ms, value);
        signal.addEventListener("abort", () => {
          clearTimeout(timer);
          reject(signal.reason);
        });
      });
    const winner = spy(wait(100, "fast"));
    let result;
    raceWithAbort([wait(300, "slow"), winner, wait(200, "medium")]).then((value) => {
      result = value;
    });
    await clock.tickAsync(300);
    expect(result).toBe("fast");
    expect(winner.calls[0].args[0].aborted).toBe(false);
    
  7. treats a task that throws as one that rejects
    const error = new Error("bad url");
    const other = spy(() => new Promise(() => {}));
    let promise;
    expect(() => {
      promise = raceWithAbort([
        () => {
          throw error;
        },
        other,
      ]);
    }).not.toThrow();
    await expect(promise).rejects.toBe(error);
    expect(other).toHaveBeenCalledTimes(1);
    expect(other.calls[0].args[0].aborted).toBe(true);
    
  8. accepts tasks that return plain values
    const slow = spy(() => new Promise(() => {}));
    await expect(raceWithAbort([slow, () => "cached"])).resolves.toBe("cached");
    expect(slow.calls[0].args[0].aborted).toBe(true);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).