Skip to content
aviral gupta

// I6.3 · ~40 min · Intermediate

Cancelling requests with AbortController

After this lesson you can cancel a request, let only the latest search win, and give your own promise-based functions a signal they obey.

Lesson 3 of 5 in I6 Network requests and client storage

You will be able to

  • Cancel a fetch with an AbortController, and read the AbortError, signal.aborted and signal.reason
  • Combine a cancel signal with a timeout using AbortSignal.any, and cancel an older search when a new one starts
  • Make your own promise-based function cancellable with an abort listener that rejects with signal.reason
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson I1.5: an event wrapped in a promise. What does this print?

    function once(target, type) {
      return new Promise((resolve) => target.addEventListener(type, resolve, {once: true}));
    }
    const target = new EventTarget();
    const ping = once(target, "ping");
    target.dispatchEvent(new Event("ping"));
    target.dispatchEvent(new Event("ping"));
    console.log((await ping).type);
  2. Predict · Activity 2 of 7

    Predict before you read on. api is a test server whose /slow answers after 300 ms. What does this print?

    const controller = new AbortController();
    const request = fetch(api + "/slow", {signal: controller.signal});
    controller.abort();
    try {
      await request;
      console.log("loaded");
    } catch (error) {
      console.log(error.name, controller.signal.aborted);
    }
  3. Practice · Activity 3 of 7

    Fill in the name of the fetch option that connects the request to the controller.

    ____: controller.signal
    : controller.signal
  4. Practice · Activity 4 of 7

    abort() can take a reason. What does this print?

    const controller = new AbortController();
    console.log(controller.signal.aborted);
    controller.abort("user left the page");
    console.log(controller.signal.aborted, controller.signal.reason);
  5. Practice · Activity 5 of 7

    One signal for two reasons to stop. What does this print?

    const user = new AbortController();
    const signal = AbortSignal.any([user.signal, AbortSignal.timeout(1000)]);
    user.abort("cancelled");
    console.log(signal.aborted, signal.reason);
  6. Brain teaser · Activity 6 of 7

    Brain teaser. /search answers after 50 ms. Two searches start at once. What does this print?

    let controller;
    async function search(term) {
      controller?.abort();
      controller = new AbortController();
      const response = await fetch(api + "/search?q=" + term, {signal: controller.signal});
      return response.json();
    }
    const results = await Promise.allSettled([search("e"), search("em")]);
    console.log(results.map((result) => result.status).join(" "));
  7. Apply · Activity 7 of 7

    Mini-task: with server.js from the worked example, write load(path, userSignal). It combines userSignal with a 100 ms timeout, fetches api + path and returns the title, "cancelled" after an AbortError, or "too slow" after a TimeoutError. Try /books/1, then /slow with a controller you abort after 20 ms, then /slow without aborting.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Cancel, time out, latest wins, and your own wait

server.js is a small test server: /slow answers after 300 ms and /search after 50 ms. main.js cancels one request, combines a cancel signal with a timeout, runs three searches of which only the last may finish, and stops its own promise-based wait() with a reason.

main.js

import {startServer} from "./server.js";

const server = await startServer();
const api = server.url;

// 1. Cancel one request.
const controller = new AbortController();
const request = fetch(api + "/slow", {signal: controller.signal});
setTimeout(() => controller.abort(), 20);
try {
  await request;
} catch (error) {
  console.log(error.name + ": " + error.message, controller.signal.aborted);
}

// 2. The user can cancel, and a timeout stops it anyway: whichever comes first.
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(50)]);
try {
  await fetch(api + "/slow", {signal});
} catch (error) {
  console.log("any:", error.name);
}

// 3. Latest wins: a new search cancels the one before.
let current = null;
async function search(term) {
  current?.abort();
  current = new AbortController();
  const response = await fetch(api + "/search?q=" + term, {signal: current.signal});
  return response.json();
}
const results = await Promise.allSettled(["d", "du", "dun"].map(search));
for (const result of results) {
  console.log(result.status, result.status === "fulfilled" ? result.value : result.reason.name);
}

// 4. Your own promise-based function, cancellable.
function wait(ms, signal) {
  return new Promise((resolve, reject) => {
    signal.throwIfAborted(); // aborted already: reject at once
    const timer = setTimeout(() => resolve("done"), ms);
    signal.addEventListener("abort", () => {
      clearTimeout(timer);
      reject(signal.reason);
    }, {once: true});
  });
}
const stop = new AbortController();
setTimeout(() => stop.abort(new Error("Cancelled by the user")), 20);
try {
  await wait(1000, stop.signal);
} catch (error) {
  console.log("wait:", error.message);
}

await server.close();

server.js

// A small test server for this lesson, made with node:http. You do not need to change it.
import {createServer} from "node:http";

const TITLES = ["Dune", "Emma", "Kindred", "Beloved"];

export async function startServer() {
  const server = createServer((request, response) => {
    const url = new URL(request.url, "http://localhost");
    const json = (status, value) => {
      response.writeHead(status, {"Content-Type": "application/json"});
      response.end(JSON.stringify(value));
    };
    const later = (ms, value) => {
      const timer = setTimeout(() => json(200, value), ms);
      response.on("close", () => clearTimeout(timer)); // the client gave up
    };
    if (url.pathname === "/books/1") json(200, {id: 1, title: "Dune"});
    else if (url.pathname === "/slow") later(300, {id: 3, title: "Kindred"});
    else if (url.pathname === "/search") {
      // the titles that contain ?q=…, after 50 ms
      const term = (url.searchParams.get("q") ?? "").toLowerCase();
      later(50, TITLES.filter((title) => title.toLowerCase().includes(term)));
    } else json(404, {error: "Not found"});
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
  return {
    url: `http://127.0.0.1:${server.address().port}`,
    close: () => new Promise((resolve) => server.close(resolve))
  };
}

Run it with

node main.js

Output

AbortError: This operation was aborted true
any: TimeoutError
rejected AbortError
rejected AbortError
fulfilled [ 'Dune' ]
wait: Cancelled by the user
  • The message “This operation was aborted” is Node.js’s; browsers word it differently, so decide by error.name.
  • The combined signal reported TimeoutError, because the timeout came before any cancel.
  • wait() rejected with the reason given to abort(), an Error of our own, not an AbortError.

Exercises

Exercise 1 of 3

A request that can be cancelled

loadBook(url, signal) should load url and return the parsed JSON, and stop when signal aborts: its promise must then reject with the AbortError. The starter ignores the signal, so a cancelled request still loads. Hand the signal to fetch.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Run the tests: the slow book still arrives after abort(). Does fetch know about the signal?

  2. Hint 2

    fetch takes the signal in its options object.

  3. Hint 3

    await fetch(url, {signal})

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export async function loadBook(url, signal) {
  const response = await fetch(url, {signal});
  return response.json();
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export async function loadBook(url, signal) {
  const response = await fetch(url);
  return response.json();
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {startServer} from './server.js';
import {loadBook} from './main.js';

const caught = (promise) => promise.then(() => null, (error) => error);

test('loadBook gives book 1', async () => {
  const server = await startServer();
  try {
    const book = await loadBook(server.url + '/books/1', new AbortController().signal);
    assert.deepEqual(book, {id: 1, title: 'Dune'}, `loadBook gave ${JSON.stringify(book)}`);
  } finally {
    await server.close();
  }
});

test('abort() makes loadBook reject with an AbortError', async () => {
  const server = await startServer();
  try {
    const controller = new AbortController();
    const loading = caught(loadBook(server.url + '/slow', controller.signal));
    setTimeout(() => controller.abort(), 20);
    const error = await loading;
    assert.equal(error?.name, 'AbortError', `loadBook should reject with an AbortError, but it gave ${error ? error.name : 'no error'}`);
  } finally {
    await server.close();
  }
});

server.js

// A small test server for this lesson, made with node:http. You do not need to change it.
import {createServer} from "node:http";

const TITLES = ["Dune", "Emma", "Kindred", "Beloved"];

export async function startServer() {
  const server = createServer((request, response) => {
    const url = new URL(request.url, "http://localhost");
    const json = (status, value) => {
      response.writeHead(status, {"Content-Type": "application/json"});
      response.end(JSON.stringify(value));
    };
    const later = (ms, value) => {
      const timer = setTimeout(() => json(200, value), ms);
      response.on("close", () => clearTimeout(timer)); // the client gave up
    };
    if (url.pathname === "/books/1") json(200, {id: 1, title: "Dune"});
    else if (url.pathname === "/slow") later(300, {id: 3, title: "Kindred"});
    else if (url.pathname === "/search") {
      // the titles that contain ?q=…, after 50 ms
      const term = (url.searchParams.get("q") ?? "").toLowerCase();
      later(50, TITLES.filter((title) => title.toLowerCase().includes(term)));
    } else json(404, {error: "Not found"});
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
  return {
    url: `http://127.0.0.1:${server.address().port}`,
    close: () => new Promise((resolve) => server.close(resolve))
  };
}

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 3

Latest search wins

makeSearch(baseUrl) returns a search(term) function that loads baseUrl + "/search?q=" + term. When a new search starts, the previous one must be cancelled, so that its promise rejects with an AbortError. The starter makes a new controller for each search but never aborts the old one.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Run the tests: the first search still finishes. Who could stop it?

  2. Hint 2

    controller still holds the previous search’s controller when search starts.

  3. Hint 3

    controller?.abort(); as the first line of search

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export function makeSearch(baseUrl) {
  let controller = null;
  return async function search(term) {
    controller?.abort();
    controller = new AbortController();
    const response = await fetch(baseUrl + "/search?q=" + term, {signal: controller.signal});
    return response.json();
  };
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export function makeSearch(baseUrl) {
  let controller = null;
  return async function search(term) {
    controller = new AbortController();
    const response = await fetch(baseUrl + "/search?q=" + term, {signal: controller.signal});
    return response.json();
  };
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {startServer} from './server.js';
import {makeSearch} from './main.js';

const caught = (promise) => promise.then(() => null, (error) => error);

test('One search gives the matching titles', async () => {
  const server = await startServer();
  try {
    const search = makeSearch(server.url);
    const titles = await search('em');
    assert.deepEqual(titles, ['Emma'], `search('em') gave ${JSON.stringify(titles)}`);
  } finally {
    await server.close();
  }
});

test('A new search cancels the one before', async () => {
  const server = await startServer();
  try {
    const search = makeSearch(server.url);
    const first = caught(search('d'));
    const titles = await search('du');
    assert.deepEqual(titles, ['Dune'], `search('du') gave ${JSON.stringify(titles)}`);
    const error = await first;
    assert.equal(error?.name, 'AbortError', `the first search should reject with an AbortError, but it gave ${error ? error.name : 'no error'}`);
  } finally {
    await server.close();
  }
});

test('Of three quick searches only the last one finishes', async () => {
  const server = await startServer();
  try {
    const search = makeSearch(server.url);
    const results = await Promise.allSettled(['d', 'du', 'dun'].map(search));
    const states = results.map((result) => result.status);
    assert.deepEqual(states, ['rejected', 'rejected', 'fulfilled'], `the searches ended ${JSON.stringify(states)}`);
  } finally {
    await server.close();
  }
});

server.js

// A small test server for this lesson, made with node:http. You do not need to change it.
import {createServer} from "node:http";

const TITLES = ["Dune", "Emma", "Kindred", "Beloved"];

export async function startServer() {
  const server = createServer((request, response) => {
    const url = new URL(request.url, "http://localhost");
    const json = (status, value) => {
      response.writeHead(status, {"Content-Type": "application/json"});
      response.end(JSON.stringify(value));
    };
    const later = (ms, value) => {
      const timer = setTimeout(() => json(200, value), ms);
      response.on("close", () => clearTimeout(timer)); // the client gave up
    };
    if (url.pathname === "/books/1") json(200, {id: 1, title: "Dune"});
    else if (url.pathname === "/slow") later(300, {id: 3, title: "Kindred"});
    else if (url.pathname === "/search") {
      // the titles that contain ?q=…, after 50 ms
      const term = (url.searchParams.get("q") ?? "").toLowerCase();
      later(50, TITLES.filter((title) => title.toLowerCase().includes(term)));
    } else json(404, {error: "Not found"});
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
  return {
    url: `http://127.0.0.1:${server.address().port}`,
    close: () => new Promise((resolve) => server.close(resolve))
  };
}

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 3 of 3

A wait you can cancel

wait(ms, signal) resolves with "done" after ms milliseconds. Make it cancellable: when signal aborts, clear the timer and reject with signal.reason. If the signal was aborted before wait was called, reject at once with its reason. The starter ignores the signal.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Run the tests: wait still resolves after abort(). Who tells the promise about the abort?

  2. Hint 2

    signal.addEventListener("abort", () => { … }, {once: true}) inside the executor, which also needs reject.

  3. Hint 3

    In the listener: clearTimeout(timer); reject(signal.reason);. Before the timer: if (signal.aborted) { reject(signal.reason); return; }

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export function wait(ms, signal) {
  return new Promise((resolve, reject) => {
    if (signal.aborted) {
      reject(signal.reason);
      return;
    }
    const timer = setTimeout(() => resolve("done"), ms);
    signal.addEventListener("abort", () => {
      clearTimeout(timer);
      reject(signal.reason);
    }, {once: true});
  });
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export function wait(ms, signal) {
  return new Promise((resolve) => {
    setTimeout(() => resolve("done"), ms);
  });
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {wait} from './main.js';

test('wait resolves with done when nobody aborts', async () => {
  const value = await wait(10, new AbortController().signal);
  assert.equal(value, 'done', `wait gave ${value}`);
});

test('abort() makes wait reject with an AbortError', async () => {
  const controller = new AbortController();
  setTimeout(() => controller.abort(), 10);
  await assert.rejects(wait(200, controller.signal), {name: 'AbortError'}, 'wait should reject with an AbortError after abort()');
});

test('An aborted signal makes wait reject with its reason', async () => {
  const controller = new AbortController();
  controller.abort(new Error('too late'));
  await assert.rejects(wait(200, controller.signal), {message: 'too late'}, 'wait should reject with the reason of a signal that is already aborted');
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Reusing an aborted controller

import {startServer} from "./server.js";

const server = await startServer();
const controller = new AbortController();
try {
  const first = fetch(server.url + "/slow", {signal: controller.signal});
  controller.abort(); // the user cancels the first request
  await first.catch((error) => console.log("first:", error.name));
  const response = await fetch(server.url + "/books/1", {signal: controller.signal});
  console.log((await response.json()).title);
} finally {
  await server.close();
}

What Node.js prints

DOMException [AbortError]: This operation was aborted

Why, and the fix

A signal is used once: after abort() it stays aborted, and every fetch that gets it rejects at once. Make a new AbortController for each request: const next = new AbortController(); fetch(url, {signal: next.signal}).

Calling abort() on the signal

const controller = new AbortController();
controller.signal.addEventListener("abort", () => console.log("aborted"));
controller.signal.abort();

What Node.js prints

TypeError: controller.signal.abort is not a function

Why, and the fix

The signal only listens; the controller decides. Call controller.abort(). Hand out only the signal to the code that does the work, and keep the controller where the decision to cancel is made.

AbortSignal.any without an array

const user = new AbortController();
const signal = AbortSignal.any(user.signal, AbortSignal.timeout(5000));
console.log(signal.aborted);

What Node.js prints

TypeError: signals cannot be converted to sequence.

Why, and the fix

AbortSignal.any takes one argument: an array (or other iterable) of signals. Put the signals in square brackets: AbortSignal.any([user.signal, AbortSignal.timeout(5000)]). This message is Node.js’s; a browser words it differently.

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

A controller and its signal

const controller = new AbortController() gives you two parts: controller.signal, which you hand to the work, as in fetch(url, {signal: controller.signal}), and controller.abort(), which you call to stop it. fetch then rejects with signal.reason, a DOMException named AbortError unless you pass your own reason, as in abort(new Error("Page closed")). signal.aborted becomes true and stays true: a signal is used once. A later fetch with the same signal rejects at once, so make a new controller for every request.

Several reasons to stop

AbortSignal.any([user.signal, AbortSignal.timeout(5000)]) makes one signal that aborts as soon as any of the signals in the array aborts, with that signal’s reason. So one fetch stops either when the user clicks Cancel (AbortError) or when time is up (TimeoutError). A search box needs “latest wins”: before a new search starts, abort the controller of the previous one. Otherwise an old answer that arrives late can overwrite the results of the newer search.

Your own functions can listen too

A signal is an EventTarget. signal.addEventListener("abort", listener, {once: true}) runs the listener when abort() is called. To make a promise-based function cancellable, like wait(ms) from lesson I1.5, take a signal, and in the listener clean up (clearTimeout) and call reject(signal.reason). Check the signal first, too: if it was aborted before you started, the event has already fired and will not fire again. signal.throwIfAborted() throws the reason in that case.

Sources

Last reviewed October 5, 2026