Cancelling async work means telling something you started, such as a request, that you no longer need its result, so it can stop. You want that whenever the result has become useless: the user typed a new search, closed the dialog, or left the page. Stopping saves bandwidth, battery, and server time, and keeps an out-of-date result from turning up later.
A promise can't be stopped from outside. It's only a placeholder for a result: it has no cancel method, and the work behind it carries on whether or not anyone is still waiting for it. So cancelling is a request, not a command. You give the work a way to hear that it should stop, and the work stops itself. Work that never listens carries on regardless.
The standard way to ask is AbortController, built into browsers and Node.js. Each controller comes with a signal, an AbortSignal. You keep the controller and hand the signal to the work; calling controller.abort() then tells everything holding the signal to stop. fetch takes a signal as an option, and aborting it cancels the request:
const controller = new AbortController();
fetch("/api/search?q=shoes", { signal: controller.signal })
.then((response) => response.json())
.then((results) => console.log(results)) // never runs
.catch((error) => console.log(error.name)); // "AbortError"
controller.abort(); // the user typed something else
console.log(controller.signal.aborted); // true
When a signal is aborted, three things happen: signal.aborted turns true, signal.reason holds the reason passed to abort(reason), and the signal fires an abort event, so work that's waiting can react straight away. A signal is aborted only once: calling abort again does nothing.
const controller = new AbortController();
const { signal } = controller;
signal.addEventListener("abort", () => console.log("abort event:", signal.reason));
signal.aborted; // false
controller.abort("The dialog was closed"); // logs "abort event: The dialog was closed"
signal.aborted; // true
signal.reason; // "The dialog was closed"
controller.abort("Again"); // does nothing: no second event, and the reason stays the same
By convention, cancelled work rejects with an error whose name is "AbortError". Calling abort() without a reason sets signal.reason to a DOMException with that name (DOMException is the error type web APIs use), and a fetch aborted that way rejects with an AbortError too. Callers check error.name === "AbortError" to tell a cancellation, which they asked for and usually ignore, from a real failure.
Signals turn up across the platform. addEventListener takes a signal option, and aborting that signal removes the listener. AbortSignal.timeout(ms) returns a signal that aborts by itself after ms milliseconds, with a DOMException named "TimeoutError" as its reason.
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.
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);
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");
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);
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);
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);
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);
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);
const slow = spy(() => new Promise(() => {}));
await expect(raceWithAbort([slow, () => "cached"])).resolves.toBe("cached");
expect(slow.calls[0].args[0].aborted).toBe(true);
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).
I'm here to help with Race that aborts the losers. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.