completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Cancellation with AbortController

Stop fetch requests, timers, event listeners, and your own asynchronous work with AbortController and AbortSignal.

By the end you can
  • 01
    Tell the controller and the signal apartKeep the power to stop in your own code, and give the work only the signal.
  • 02
    Stop browser work safelyStop fetch requests, body reads, timers, and event listeners, and leave no timers running.
  • 03
    Write your own cancellable functionsAccept { signal }, check first, listen once, clean up, and reject with the signal's reason.

Why cancellation exists

Some JavaScript work takes time, like loading data from a server or waiting for a timer. This is called asynchronous work: it finishes later, while the page keeps running. Sometimes that work stops being useful before it finishes. For example:

  • The user typed a newer search.
  • The user moved to another page.
  • The user pressed a Stop button.
  • The work took too long.

Cancellation means telling that old work to stop. When the work hears this, it should do three things:

  • Stop as soon as it can.
  • Clean up what it started, like a timer.
  • Reject its promise, so the code that waits for it can move on.

You already know promises, timers, and try...catch. Cancellation uses all three. The work gets a signal: an object that says whether to stop. The work checks the signal. If the signal says stop, the work rejects its promise with a reason: a value that says why it stopped.

Real-life analogyCancelling a restaurant order

You order fries. Then you see that you are at the wrong table. You ask the waiter to cancel. If the kitchen has not started, no fries get made. If the fries are already cooked, cancelling does not uncook them.

In real life: You tell the waiter to cancel
In JavaScript: Your code calls controller.abort()
In real life: The kitchen stops if it can
In JavaScript: The work clears its timers, listeners, or request
In real life: Food already cooked stays cooked
In JavaScript: Work the server already did is not undone

Requests from the browser work the same way. You can stop waiting for an answer. But the server may have done the work already.

The main idea

To abort means to stop something before it ends. An AbortController is an object with an abort() method. It makes an AbortSignal, which you find at controller.signal. You give the signal to the work. When you call controller.abort(), the signal says "stop", and the work can stop.

The signal remembers its first reasonPop out in the code editor (opens in a new tab)JavaScript
const controller = new AbortController();
console.log(controller.signal.aborted);

controller.abort(new DOMException("Too slow", "TimeoutError"));
console.log(controller.signal.aborted, controller.signal.reason.name);

Line 1 makes a new controller. Line 2 prints controller.signal.aborted. aborted is true or false: has this signal been told to stop? It prints false, because nothing has stopped it yet.

Line 4 calls abort() and passes a reason. The reason here is a DOMException: a built-in error object that browsers use. Its first part is a message, "Too slow". Its second part is a name, "TimeoutError".

Line 5 prints true TimeoutError. The signal has stopped, and signal.reason keeps the reason you passed. A signal can stop only once. After that, it stays stopped and keeps its first reason.

A signal before and after abort(). Before, the signal cart holds the card aborted: false. An arrow labelled abort() leads to the same signal, now holding aborted: true, with the reason TimeoutError.aborted: falsesignalnot stopped yetabort()aborted: truesignalreason: TimeoutError
abort() turns aborted from false to true and saves the reason. A signal stops only once, so it stays like this.

AbortController & AbortSignal

INTERACTIVE

Cancellation uses two objects. Each has its own job:

  • The AbortController can stop the work. It has the method abort(reason). The reason is optional.
  • The AbortSignal is what the work gets. It tells the work about a stop, but it can't cause one.

The signal gives the work four things:

  • aborted: true once the signal has stopped, false before.
  • reason: the value that says why it stopped.
  • An abort event: it fires the moment the signal stops, so the work can react.
  • throwIfAborted(): a method that throws the reason if the signal has already stopped.

The rule is: keep the controller, and give out only the signal. Then other code can hear a stop, but it can't stop your work by mistake.

A controller cart holds two cards: abort() and signal. An arrow labelled { signal } carries only the signal card to a box called fetch. You keep the controller; fetch only listens.abort()signalcontrolleryou keep this{ signal }signalfetchfetch only listens
The controller holds both abort() and the signal. Give the work only the signal. Then it can hear a stop, but it can't cause one.
Real-life analogyA referee's whistle

A referee has a whistle. The players don't get whistles. They only listen for one.

In real life: The referee owns the whistle
In JavaScript: Your code owns new AbortController()
In real life: Players listen for the whistle
In JavaScript: The work gets controller.signal
In real life: One whistle blast stops play
In JavaScript: A signal stops once and stays stopped

In code, the same rule stops a helper function from cancelling work that it doesn't own. Sort each job into the right group before you try the bigger demo below.

Controller or signal?
  • Has the .abort(reason) method
  • Passed to fetch(url, { signal })
  • Has .aborted and .reason
  • TimeoutError from AbortSignal.timeout(1000)
  • Can't be reset after it stops
  • Usually kept private by the code that started the work
Try it yourself
0 of 6 correct

Decide which one does each job: the controller, the signal, or the reason.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Next, try a pretend download. It doesn't use the network. It uses timers, so you can watch each piece arrive. Each piece is called a chunk. The demo follows the same steps as a real cancellable request:

  1. Check the signal before starting.
  2. Pass the signal to each wait.
  3. When the signal stops, clear the timer that is still waiting.
  4. Reject with the signal's reason.
Cancellable download
A cancellable downloadPop out in the code editor (opens in a new tab)JavaScript
async function downloadFile({ signal }) {  signal.throwIfAborted();  for (const chunk of [1, 2, 3, 4]) {    await wait(350, { signal });    progress.value = chunk * 25;  }  return "download complete";}
Live stateidle
0%
progress0%
signal.abortedfalse
signal.reason.name—
active timers0
Log
  1. Press Start to make a new controller.
Try it yourself

Each new download gets a new controller. Start it plainly, with a 1-second time limit, or with both the Abort button and a time limit.

A pretend download made of timers. Reset stops the current run and clears every timer. AbortSignal.timeout and AbortSignal.any work in modern browsers and in Node 20.3 and later.

Look at the reason names in the demo:

  • controller.abort() with no reason gives a DOMException named AbortError.
  • AbortSignal.timeout(ms) stops by itself with a DOMException named TimeoutError.

A signal stops only once. Its abort event fires right away, during the abort() call, before the next line of code runs.

Four things to remember
  • signal.aborted becomes true during the abort() call. The very next line already sees true.
  • A reason can be a DOMException, an Error, or any other value. So check that the reason is an object before you read its name.
  • abort() does nothing to work that never got the signal. The work has to listen. JavaScript can't force it to stop.
  • Keep the controller private. A helper that only needs to hear about a stop should get the signal, not the power to stop.

Cancelling fetch

BROWSER CHECK

fetch asks a server for data and returns a promise. It is the most common place to use a signal. You pass the signal inside the options object: fetch(url, { signal }).

If the signal stops before the request finishes, the fetch promise rejects with the signal's reason. After a plain abort(), that reason is an AbortError.

Real fetch cancellation in your browser
Fetch with a stopped signalPop out in the code editor (opens in a new tab)JavaScript
const signal = AbortSignal.abort(); try {  await fetch(location.href, { signal });} catch (error) {  console.log(error.name);}
Browser resultthis page
catchClick the button to fetch this page with a signal that has already stopped.
Try it yourself

The signal has already stopped, so fetch rejects before it does any real work. The demo fetches this page's own address, location.href, not another website.

Modern fetch rejects with the signal’s reason. Older browsers may always say AbortError.

A response comes in two parts. The headers arrive first; they describe the response. The body is the data itself, and it can keep arriving after the headers. So a stop can still matter after the headers arrive: if your code is still reading the body, that read fails.

Abort does not undo work on the server. Say your request asks the server to create an order. The server may have created it already. Stopping the request doesn't delete the order.

Treat a stop differently from a failure

When a user presses Stop, that is not an error to warn them about. In your catch block, check error.name or signal.aborted. Then you can ignore an expected stop and still show real network errors.

A promise does not stop by itselfPop out in the code editor (opens in a new tab)JavaScript
const controller = new AbortController();
const work = new Promise((resolve) => {
  setTimeout(() => resolve("finished"), 0);
});

controller.abort();
console.log(controller.signal.aborted);
work.then((value) => console.log(value));

Lines 2 to 4 make a promise. Its timer resolves it with "finished". Line 6 stops the controller. Line 7 prints true, so the signal did change. But line 8 still prints finished, because the promise never got the signal. Nothing told it to listen.

This is why cancellation is called cooperative: the work has to help. It must check or listen for the signal, and it must clean up its own timers.

AbortSignal.timeout & AbortSignal.any

ALARMS

You don't always need to make a controller yourself. AbortSignal has three helpers that make a signal for you:

  • AbortSignal.abort(reason) gives a signal that has already stopped.
  • AbortSignal.timeout(ms) gives a signal that stops by itself after ms milliseconds.
  • AbortSignal.any([...]) joins several signals into one. The joined signal stops as soon as the first of them stops.

AbortSignal.any works in all main browsers since 2024 ("Baseline 2024"), and in modern Node.

Real-life analogyEgg timers and alarm clocks

An egg timer rings by itself after a set time. You don't have to watch the clock. Now put several alarm clocks around your room. As soon as any one rings, you wake up. And you know which one woke you.

In real life: An egg timer rings after a set time
In JavaScript: AbortSignal.timeout(1000) stops by itself after 1 second
In real life: Any alarm can wake you
In JavaScript: AbortSignal.any([user, deadline]) stops when the first one stops
In real life: You know which alarm woke you
In JavaScript: The joined signal keeps the first reason
Three signal helpersPop out in the code editor (opens in a new tab)JavaScript
const alreadyDone = AbortSignal.abort(new DOMException("Nope", "AbortError")); const deadline = AbortSignal.timeout(1000); const controller = new AbortController();const cancelOrDeadline = AbortSignal.any([controller.signal, deadline]);

Line 1 makes a signal that has already stopped. Line 3 makes a signal that stops itself after 1000 milliseconds, which is 1 second. Line 6 joins your own controller's signal and the deadline into one signal.

The joined signal keeps the first reasonPop out in the code editor (opens in a new tab)JavaScript
const user = new AbortController();
const deadline = AbortSignal.timeout(1000);
const stop = AbortSignal.any([user.signal, deadline]);

user.abort(new DOMException("Left page", "AbortError"));
console.log(stop.reason.name);

Line 3 joins the user's signal and a 1-second deadline. Line 5 stops the user's signal right away, long before the deadline. So line 6 prints AbortError, not TimeoutError.

Two signals, user.signal and deadline, both lead into one joined signal box called stop. user.signal stops first, so stop holds the reason AbortError. The deadline would only stop after 1000 ms.user.signalstops firstdeadlinewaits 1000 msAbortErrorstopfirst reason wins
AbortSignal.any joins both signals into stop. The user stops first, so stop keeps that reason: AbortError.

When to use each one:

  • Use timeout when slow work should fail by itself after a while.
  • Use any when several things can stop the same work. For example: the user left the page, or a deadline passed.

Make a timeout signal right before the work it guards. Its clock starts when you call AbortSignal.timeout, not when a later fetch starts. If you want both a user stop and a deadline, make both signals first. Then join them with any, and pass only the joined signal to the work.

Making your APIs cancellable

STEP THROUGH

Built-in functions like fetch accept a signal. Your own functions should too. The usual way is an options object with a signal property: sleep(800, { signal }). With an options object, you can add more options later, like retries, and the way people call the function stays the same.

The example below is sleep: a function that waits ms milliseconds and then resolves. It writes signal?. with a question mark. That is optional chaining: if no signal was passed, the call is skipped instead of throwing an error.

A cancellable sleep functionPop out in the code editor (opens in a new tab)JavaScript
function sleep(ms, { signal } = {}) {  signal?.throwIfAborted();  return new Promise((resolve, reject) => {    const onAbort = () => {      clearTimeout(timer);      reject(signal.reason);    };    const timer = setTimeout(() => {      signal?.removeEventListener("abort", onAbort);      resolve("slept");    }, ms);    signal?.addEventListener("abort", onAbort, { once: true });  });} const controller = new AbortController();const promise = sleep(800, { signal: controller.signal });controller.abort(new DOMException("Stopped", "AbortError"));

Step through it below. Pick when the caller stops the work, then click through it one line at a time.

Step through a cancellable sleep
Step 0 of 10Ready
Your turn: follow the blue line

Pick when the caller stops the work. Guess whether sleep starts a timer, then step through the code.

Running in
  1. script
Next: line 16
function sleep(ms, { signal } = {}) {  signal?.throwIfAborted();  return new Promise((resolve, reject) => {    const onAbort = () => {      clearTimeout(timer);      reject(signal.reason);    };    const timer = setTimeout(() => {      signal?.removeEventListener("abort", onAbort);      resolve("slept");    }, ms);    signal?.addEventListener("abort", onAbort, { once: true });  });} const promise = sleep(800, { signal: controller.signal });controller.abort(new DOMException("Stopped", "AbortError"));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Pick when the caller stops the work

Changing this starts the replay again.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

The pattern has five steps, in this order:

  1. Check first with signal?.throwIfAborted() (line 2). If the signal has already stopped, this throws, and nothing starts.
  2. Start the real work, here a timer (line 8).
  3. Listen for the abort event (line 12). { once: true } removes the listener after it runs once.
  4. On abort, clear the timer and reject with signal.reason (lines 5 and 6).
  5. On success, remove the listener and resolve as usual (lines 9 and 10).
Check before starting workPop out in the code editor (opens in a new tab)JavaScript
const signal = AbortSignal.abort(new DOMException("Too slow", "TimeoutError"));

try {
  signal.throwIfAborted();
  console.log("started");
} catch (error) {
  console.log(error.name);
}

The signal on line 1 has already stopped, with a TimeoutError reason. So throwIfAborted() on line 4 throws that reason. Line 5 never runs, so started is never printed. The catch block prints TimeoutError on line 7.

This check stops a cancelled call before it starts a timer or a request.

Clear the timer when the signal stopsPop out in the code editor (opens in a new tab)JavaScript
function waitForTea(ms, { signal } = {}) {
  signal?.throwIfAborted();
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => resolve("tea ready"), ms);
    signal?.addEventListener("abort", () => {
      clearTimeout(timer);
      reject(signal.reason);
    }, { once: true });
  });
}

const controller = new AbortController();
const tea = waitForTea(1000, { signal: controller.signal });
controller.abort();
tea.catch((error) => console.log(error.name));

Line 4 starts a timer that would resolve with "tea ready" after 1 second. Line 14 stops the controller right away. The listener on lines 5 to 8 runs: it clears the timer and rejects the promise. Line 15 catches the rejection and prints AbortError, the name of the default reason.

Search as you type: old results win the race

INTERACTIVE

A search box often sends a request as you type. Say the request for c is slow, and the request for cat is fast. Then the old c answer can arrive last. It replaces the newer cat results on the screen.

This bug is called a race condition: the result depends on which request finishes first. The promises work fine. The problem is the order.

In the demo, you "type" c, then ca, then cat, 120 milliseconds apart. Each search takes a fixed time, so the bug shows up every time. Run it with "Abort previous" off, then on.

Search as you type
Stop the old search firstPop out in the code editor (opens in a new tab)JavaScript
let currentController; async function runSearch(query) {  currentController?.abort(new DOMException("Newer search", "AbortError"));  currentController = new AbortController();  const { signal } = currentController;  const results = await fakeSearch(query, { signal });  render(results);}
On screenabort off
screenNo search yet
Request log
  1. Press Run typing to type c, then ca, then cat.
Try it yourself

Nothing stops the old searches. The slow search for c finishes last and replaces the newer results. That is the race condition.

Each search takes a fixed time, so the bug shows up every time. The pretend search uses real timers and real signals.
Two consoles for the searches c, ca, and cat. Left, no abort: cat is done at 460 ms, ca at 770 ms, and c at 900 ms, so the screen ends up showing Results for c. Right, abort old: c and ca are aborted, and cat is done at 460 ms, so the screen shows Results for cat.no abortcat: done at 460 msca: done at 770 msc: done at 900 msshows Results for cabort oldc: abortedca: abortedcat: done at 460 msshows Results for cat
Typing starts searches for c, ca, and cat, 120 ms apart. Without abort, the slow c search finishes last and wins. With abort, only cat is left to finish.

The fix is to keep the last controller in a variable. Before you start a new search, stop the old controller. Then make a new one. Only the newest search can show its results.

Only the newest search shows its resultsPop out in the code editor (opens in a new tab)JavaScript
function fakeSearch(query, { signal }) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => resolve("Results for " + query), 0);
    signal.addEventListener("abort", () => {
      clearTimeout(timer);
      reject(signal.reason);
    }, { once: true });
  });
}

let currentController;

async function searchLatest(query) {
  currentController?.abort(new DOMException("Newer search", "AbortError"));
  currentController = new AbortController();
  const { signal } = currentController;

  try {
    const results = await fakeSearch(query, { signal });
    console.log(results);
  } catch (error) {
    if (signal.aborted) return;
    throw error;
  }
}

const oldSearch = searchLatest("cat");
const newSearch = searchLatest("cats");
Promise.all([oldSearch, newSearch]);

Line 27 starts a search for cat. Line 28 starts a search for cats. That second call stops the first search on line 14. So only the newer search prints Results for cats.

Line 16 copies the signal into a local variable, signal. Each call gets its own. So on line 22, the old search checks its own signal, sees that it stopped, and quietly returns.

Debouncing and cancellation solve different problems

Debouncing means waiting a short time after the last key press before you send a request. It cuts down how many requests you send. Cancellation stops a request that has already started. A search box often uses both: wait a moment after typing, then stop the old request when a new one starts.

Abort can remove event listeners too

REAL BUTTON

addEventListener also accepts a signal option. When that signal stops, the browser removes the listener for you.

This helps when you add several listeners for a short time. For example:

  • a pop-up menu,
  • dragging something across the page,
  • a keyboard shortcut that only works for a while.

Give all of them the same signal. Then one abort() call removes them all.

One abort removes several listeners
Remove two listeners at oncePop out in the code editor (opens in a new tab)JavaScript
const controller = new AbortController();const { signal } = controller; button.addEventListener("click", countClick, { signal });window.addEventListener("keydown", onKey, { signal });controller.abort(); // removes both listeners
Real buttonlisteners active
clicks counted0
C presses counted0
Try it yourself

The demo button and the C key each have a listener. Both listeners use the same signal.

This uses real addEventListener calls with the { signal } option.

Where you’ll use this

Use cancellation wherever old work can stop being useful before it finishes:

  • Search boxes that send a request as you type.
  • Page changes: stop loading data for a page the user left.
  • Stop buttons for uploads, downloads, imports, and exports.
  • Time limits for slow work.
  • Short-lived event listeners that should all go away together.
Load only the newest profilePop out in the code editor (opens in a new tab)JavaScript
let currentController; export async function loadProfile(userId) {  currentController?.abort(new DOMException("Newer profile", "AbortError"));  currentController = new AbortController();  const { signal } = currentController;   try {    const response = await fetch("/api/users/" + userId, { signal });    return await response.json();  } catch (error) {    if (signal.aborted) return null;    throw error;  }}

Line 4 stops the request for the previous profile. Line 5 makes a fresh controller for this request, and line 6 keeps its signal in a local variable. Line 9 passes that signal to fetch. Line 12 returns null when this request was stopped on purpose. Line 13 throws any other error again.

Misconceptions and edge cases

COMMON MISTAKES

Each row compares two ideas that are easy to mix up. Read it as: which ideas, what is true, and what mistake to avoid.

Cancellation ideas that are easy to mix up
Which ideas?What is trueWhat to avoid
Abort or undo?Abort stops the waiting, and stops work in your page.Don't expect it to undo work the server already did.
AbortError or TimeoutError?The reason's name tells you why the signal stopped.Don't assume every stop has the same name.
Controller or signal?The controller stops the work. The signal tells the work about the stop.Don't pass the controller around. Then any code could stop your work.
Reject or clean up?Rejecting the promise tells the waiting code that the work stopped.Rejecting doesn't clear timers or listeners. You still have to do that yourself.

A good question to ask about any function is: "What is still running after this function stops?"

  • A timer: clear it with clearTimeout.
  • A listener: remove it, or add it with a signal in the first place.
  • A fetch: pass it the signal, and handle the rejection.
  • Work on the server: abort can't undo it. The server needs its own safety check, for example ignoring the same order when it arrives twice.
  • "Promises can be cancelled." A promise only reports when work ends. The work itself must listen to a signal.
  • "Abort undoes everything." It doesn't undo work that already finished, especially on a server.
  • "I can reuse one controller." A signal stops only once. Make a new controller for each piece of work.
  • "A timeout is just an AbortError." Timeout signals use a reason named TimeoutError.
  • "Once fetch has a response, abort doesn't matter." Reading the response body can still fail after a stop.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict the reason

Read the code and type the exact error name it prints.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const signal = AbortSignal.abort(new DOMException("Too slow", "TimeoutError"));
try {
  signal.throwIfAborted();
} catch (error) {
  console.log(error.name);
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeWrite a cancellable delay

    Finish the helper so callers can pass { signal }.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    function delay(ms, { signal } = {}) {
      // Your code here
    }
      Exercise 3 · PracticeFind the old-result bug

      What text is left on the screen after these two renders?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      let rendered = "";
      function render(value) { rendered = value; }
      render("Results for cat");
      render("Results for c");
      console.log(rendered);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeA signal keeps its first reason

        Predict the reason name that gets printed.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const controller = new AbortController();
        controller.abort();
        controller.abort(new DOMException("Later", "TimeoutError"));
        console.log(controller.signal.reason.name);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeDesign a search helper where the newest search wins

          Sketch a helper where only the newest search can win.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          let currentController;
          
          async function searchLatest(query) {
            // abort the old one, create a new one, pass the signal to fetch
          }

            Check your understanding

            7 QUESTIONS
            Cancellation quiz · 7 questionsScore: first tries count
            1. Question 1 of 7A function should be able to hear a stop, but not cause one. What do you pass to it?

              Choose an answer to see the explanation.

            2. Question 2 of 7What does this code print about aborted?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const controller = new AbortController();
              controller.abort();
              console.log(controller.signal.aborted);

              Choose an answer to see the explanation.

            3. Question 3 of 7What reason does controller.abort() give when you pass no reason?

              Choose an answer to see the explanation.

            4. Question 4 of 7What does this code print when the signal has its own reason?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const signal = AbortSignal.abort(new DOMException("Too slow", "TimeoutError"));
              try {
                signal.throwIfAborted();
              } catch (error) {
                console.log(error.name);
              }

              Choose an answer to see the explanation.

            5. Question 5 of 7What does AbortSignal.any([a, b]) give you?

              Choose an answer to see the explanation.

            6. Question 6 of 7What does this code print after two abort() calls?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const controller = new AbortController();
              controller.abort();
              controller.abort(new DOMException("Late", "TimeoutError"));
              console.log(controller.signal.reason.name);

              Choose an answer to see the explanation.

            7. Question 7 of 7Why should a cancellable function clear its timers and remove its listeners?

              Choose an answer to see the explanation.

            Key takeaways

            • Keep the AbortController. Give out only the AbortSignal.
            • A signal stops only once, and it keeps its first reason.
            • fetch rejects with the signal's reason. Handle a stop apart from real errors.
            • AbortSignal.timeout makes a time limit. AbortSignal.any joins several ways to stop.
            • A cancellable function checks first, listens once, cleans up, rejects with signal.reason, and passes the signal on.

            Cancellation is teamwork: your code stops a signal, and the work notices, cleans up, and rejects with the reason.

            Up next: Async patterns.

            CompleteFrontend Clear concepts. Working examples.