Click Cancel. The spinner disappears. The button becomes clickable again.

Meanwhile, the request is still running.

This is easy to miss in a UI review because the screen looks correct. You only notice when an old response overwrites newer results, a batch keeps starting jobs, or an upstream service continues processing work the user no longer wants.

For an API tool with AI-assisted operations, cancellation is part of the user's control over the work. We build Powerduck, so this is a product concern as well as a JavaScript concern.

Here is how to reason about it without pretending one browser call can stop every system downstream.

Start by separating three promises

A cancel action can mean:

  1. Stop showing this result.
  2. Abort this client's request.
  3. Stop the server-side operation.

Those are different promises. Your UI should make the promise your system can actually keep.

For a local search, ignoring an old result may be enough. For an export job, users may expect a cancellation request to reach the worker. For a purchase already committed, canceling the HTTP request does not undo the purchase.

The first design decision is therefore a lifecycle decision, not an icon choice.

Abort the request and guard the result

The browser's AbortController can abort a fetch and consumption of its response body. Pass its signal into the operation; changing a loading boolean does not do that.

This browser example also prevents an older request from repainting the UI after a newer run starts:

let activeRun = null;

async function analyze(payload) {
  activeRun?.controller.abort();
  const run = { controller: new AbortController() };
  activeRun = run;
  setState("running");

  try {
    const response = await fetch("/api/analyze", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
      signal: run.controller.signal,
    });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const result = await response.json();

    if (activeRun !== run || run.controller.signal.aborted) return;
    showResult(result);
    setState("complete");
  } catch (error) {
    if (activeRun !== run) return;
    if (run.controller.signal.aborted) {
      setState("canceled");
    } else {
      showError(error);
      setState("failed");
    }
  } finally {
    if (activeRun === run) activeRun = null;
  }
}

function cancel() {
  activeRun?.controller.abort();
}

setState, showResult, and showError stand in for your UI code. This example cancels the client operation; it does not implement server-side job cancellation.

The identity check matters even when you use an abort signal. A user can start another run while the previous one is settling. The previous run no longer owns the screen.

A batch has work that has not started yet

Now imagine reviewing 20 API endpoints.

Canceling the active fetch is only half the job if the loop immediately starts endpoint number seven.

A sequential batch should check the same cancellation signal before starting each item and pass it to the request for that item:

async function reviewBatch(items, signal, reviewOne) {
  const results = [];
  for (const item of items) {
    signal.throwIfAborted();
    results.push(await reviewOne(item, signal));
  }
  return results;
}

reviewOne must actually forward the signal to its network operation. If you use a concurrent worker pool, stop dequeuing new work and cancel each active operation. Aborting one controller that no worker uses achieves nothing.

Also decide what happens to completed results. In a review tool, preserving finished suggestions can save users from repeating work. Label the run as partially completed rather than presenting it as a clean failure or a complete success.

Follow cancellation across the server boundary

The browser cannot prove that an upstream operation stopped.

If your backend proxies the request, it needs a cancellation path to the upstream client. If it creates a durable job, consider an explicit cancellation operation with a job ID and an observable job state.

Do not wire an arbitrary connection event to cancellation without checking your framework's lifecycle. A normally completed request and an abandoned response are not interchangeable events.

At every boundary, ask: does this library accept a signal or cancellation token? What happens after headers arrive? What if the work has already committed?

Provider behavior also matters. Aborting a connection does not establish that remote computation stopped, and it does not establish that usage already incurred will be reversed. UI copy should not promise either without a real guarantee.

Test the moment between two states

The happy path is the least interesting cancellation test. Try these instead:

  • Cancel before the first request starts: no item should launch afterward.
  • Cancel while the response body is arriving: no partial result should be applied.
  • Cancel, then immediately start again: the old run must not reset the new run's state.
  • Cancel halfway through a batch: completed results and unstarted items should remain distinguishable.
  • Complete an operation just as cancellation arrives: the recorded outcome must reflect what actually happened.

Use a controlled test server or fake upstream that records which requests start and when connections close. For durable jobs, inspect worker state too. A screenshot of a stopped spinner is not evidence that the worker stopped.

In a workspace like Powerduck, users move between requests, analysis, and review. Making those transitions understandable matters as much as making them fast. A useful cancel interaction tells people what stopped and what remains.

When did you last test your Cancel button against the server's behavior, rather than the loading state?

Generated with AI from Powerduck product-development discussions. Code samples illustrate cancellation patterns and require integration with your application's lifecycle.