---
name: abortcontroller-cancellation
description: "Audits and fixes cancellation in JavaScript: fetch with no signal, timeouts never cleared, reused AbortControllers, leaking abort listeners, stale responses in React effects, AbortError versus TimeoutError, and APIs that ignore a signal. Runs a scanner, then edits flagged lines only. Use for 'cancel fetch', 'AbortController', 'request timeout', 'MaxListenersExceededWarning' or search-as-you-type race conditions. Do NOT use for Promise combinators or error handling, debouncing, or server-side request limits."
license: CC-BY-4.0
metadata:
  author: Afeez Adebayo
  author_url: https://afeezadebayo.com
  author_linkedin: https://www.linkedin.com/in/afeez-adebayo/
  author_x: https://x.com/za_icon
  source: https://afeezadebayo.com/skills-md/frontend-development/javascript/abortcontroller-cancellation/
  category: frontend-development
  subcategory: javascript
  version: "1.0.0"
  created: "2026-11-05"
  updated: "2026-11-05"
---

# AbortController Cancellation: Stop Work You No Longer Want

An agent works on cancellation in this order: list every operation that can outlive the answer someone wants, decide who owns each controller, run the scanner, fix timeouts first, then make the code's own async functions accept a signal, then clean up listeners, then separate cancel from failure in every catch, and re-run the scanner and the tests. The scanner reads text. It cannot tell whether a library honours a signal, so it reports patterns for a person or agent to confirm.

Reusable code is in `references/patterns.md`. Availability notes and the rival comparison are in `references/appendix.md`.

## When to use this skill, and when not to

Use it when a request keeps running after the user left the page, a search box shows an older answer over a newer one, a timeout fires but the work carries on, `MaxListenersExceededWarning` mentions abort listeners, or a function should stop between steps when asked.

| Request | Use instead |
|---|---|
| Choosing `all`, `allSettled`, `any` or `race`, unhandled rejections, `forEach(async)` | `promises-async-await.md` |
| Debouncing or throttling input | Out of scope |
| Server-side timeouts, rate limits and queues | Out of scope |
| Cancelling work inside a worker thread or a child process | Out of scope. Check that runtime's own documentation |
| Retrying failed requests at application level | A state and data fetching skill. Out of scope |

## Guardrails

1. **Propose, then change.** Report first. Edit only after the user approves a batch, on a branch under version control. Never push, merge or deploy.
2. **Aborting a request does not undo a server-side effect.** The sources read describe what the client does (the promise rejects and the body read stops), not what the server does. Treat this as an inference. Do not add cancellation to a payment, order or email call without the user confirming what a half-sent request should do.
3. **Never run the user's code against live services to test a fix.** Use the project's tests, mocks or a test environment.
4. **Data handled.** The scanner prints file, line, code and a fixed message, never source text. URLs and headers in the code can hold tokens, user ids or email addresses: do not paste them into prompts or reports. Test with fake URLs.
5. **Nothing is invented.** Every rule carries a source and a date. Version-dependent behaviour carries **verify before relying on this**. Timeout values come from the user, not from the agent.
6. **Refuses to:** invent a timeout value, swallow `AbortError` and `TimeoutError` together to quiet a log, share one controller across unrelated operations to save code, or declare a fix verified without re-running the scanner and the tests.

## Inputs the agent must have before starting

Ask for every missing item. Do not assume.

| Input | Ask exactly |
|---|---|
| Scope | "Which files or folders should I review? Give paths." |
| Runtime | "Node, browser or both? Which Node major version and which browsers must be supported?" |
| Symptom | "What goes wrong: stale results, work after leaving a page, a timeout that does not stop work, a listener warning?" |
| Timeouts | "What is the longest each kind of request may take? Give a number in milliseconds for each, or say there is no limit." |
| Writes | "Which of these calls change data on a server? What should happen if one is cancelled halfway?" |
| Framework | "Is there React, Vue, Svelte or Angular code with effects or lifecycle hooks in scope?" |
| Tests | "What command runs the tests, and is it safe to run here?" |
| Approval | "May I edit on a branch in batches and show each diff first?" |

## Workflow

### Step 1: Decide what must be cancellable, and who owns the controller

| Case | Owner of the controller | Signal source |
|---|---|---|
| User leaves or a component unmounts | The component or page code that starts the work | New controller per effect run, aborted in cleanup |
| A newer request replaces an older one (search as you type) | The same place | New controller per request, abort the previous one first |
| Time limit | Nobody: use `AbortSignal.timeout(ms)` | Timeout signal |
| Time limit and user cancel together | The caller | `AbortSignal.any([userSignal, AbortSignal.timeout(ms)])` |
| Shutdown of a service | The application root | One controller, aborted once |
| Library function you write | The caller, not the function | The function accepts `{ signal }` |

Rule from MDN: a signal is single use. After it aborts, any later `fetch` that receives it rejects at once. One controller per operation, or per group of operations that must stop together.

### Step 2: Run the scanner

```
python3 scripts/abort_check.py src | head -60
```

Read only its output. Do not read the script. The first line has the counts. `--strict` exits 1 on warnings. It skips `node_modules`, `dist`, `build` and `.git`.

| Code | Level | Meaning | Fix |
|---|---|---|---|
| `fetch-no-signal` | warn | `fetch()` call with no `signal` in it | Steps 1 and 3 |
| `timeout-manual` | warn | `setTimeout` that calls `abort()` and no `clearTimeout` in the file | Step 3 |
| `abort-name-only` | warn | Checks `AbortError` in a file that uses `AbortSignal.timeout` but never `TimeoutError` | Step 6 |
| `controller-module-scope` | warn | Controller created at the top level and aborted somewhere | Step 1 |
| `effect-fetch-no-abort` | warn | `useEffect` starts a request with no abort in cleanup | Step 5 |
| `listener-no-once` | warn | `addEventListener("abort", ...)` without `{ once: true }` | Step 7 |
| `listener-once-no-remove` | info | `once: true` but no `removeEventListener("abort", ...)` in the file | Step 7 |
| `signal-not-checked` | info | Function takes a signal and loops with `await`, never calls `throwIfAborted` | Step 4 |
| `catch-no-abort-check` | info | File uses a signal and no catch checks the error name | Step 6 |

The scanner splits on lines and reads call arguments within 12 lines. A hit in a string, template or comment is a false positive: read the line. It cannot see a signal passed through a wrapper, and it flags every `fetch` that has no `signal` in the call, including ones that need none, such as a fire-and-forget beacon.

### Step 3: Fix timeouts

Source: MDN, AbortSignal, fetched 5 November 2026.

| Situation | Change |
|---|---|
| `setTimeout(() => controller.abort(), ms)` | `AbortSignal.timeout(ms)` as the signal. No timer to clear |
| `Promise.race([work, timer])` | Pass `AbortSignal.timeout(ms)` to the work. A race leaves the loser running (MDN, Promise) |
| Timeout and user cancel both needed | `AbortSignal.any([cancel, AbortSignal.timeout(ms)])` |
| Manual timer must stay (runtime lacks the static methods) | Clear it in `finally` |

`AbortSignal.timeout()` rejects with a `DOMException` named `TimeoutError`. `controller.abort()` rejects with `AbortError`. An abort after the response arrived but before the body is read rejects the body read with `AbortError`. The timeout value comes from the user (inputs): never choose one.

`AbortSignal.any()` is Baseline newly available since March 2024 (web-platform-dx release notes, fetched 5 November 2026). **Verify before relying on this** if the supported browsers or Node versions predate it, and use a manual link to the parent signal instead (`references/patterns.md`, section 3).

### Step 4: Make your own async functions abortable

Source: DOM Standard, API author guidance, fetched 5 November 2026.

| Rule | Code shape |
|---|---|
| Accept the signal as an option | `async function load(url, { signal } = {})` |
| Reject immediately if already aborted | `signal?.throwIfAborted()` as the first line |
| Reject with the signal's reason, not a new error | `reject(signal.reason)` |
| Pass the signal down to every call that accepts one | `fetch(url, { signal })` |
| Check between steps of a long loop | `signal?.throwIfAborted()` at the top of each iteration |
| Stop timers and listeners you started | Clear and remove them in the abort path and in the normal path |

If a function wraps an API that takes no signal (a database driver, a legacy library), you can stop waiting but not stop the work: race the call against the signal, reject with `signal.reason`, and tell the user the underlying work continues. Do not report it as cancelled.

### Step 5: Stale responses in effects and search-as-you-type

| Pattern | Problem | Fix |
|---|---|---|
| `useEffect(() => { fetch(...).then(setRows) }, [q])` | A slow response for an old `q` can overwrite a newer one | New controller inside the effect, `signal` into `fetch`, `return () => controller.abort()` |
| A `cancelled` or `ignore` flag only | Stops the state update, does not stop the request | Add the controller as well, so the network work stops |
| Search box that fires on every key | Many requests in flight | Abort the previous controller before starting the next. Debouncing is out of scope |
| Catch block that sets an error state | A cancelled request shows an error message | Return early on `AbortError` (step 6) |

Pass an `AbortController` per run, never one stored outside the effect (scanner code `controller-module-scope`).

### Step 6: Separate cancel from failure

| Error name | Cause | Handle as |
|---|---|---|
| `AbortError` | `controller.abort()` or an aborted signal | Not a failure: return quietly or clean up |
| `TimeoutError` | `AbortSignal.timeout()` elapsed | Failure with a user message such as "took too long" |
| Anything else | Network or application error | Failure: log with context and rethrow or show |

Check `err.name`, not `err instanceof Error`: the sources show a `DOMException` with a `name`. With `AbortSignal.any()`, the DOM Standard copies the reason of the source signal that aborted, so `signal.reason.name` tells the cause, while the MDN page says the fetch rejection alone cannot tell a timeout from another abort. The two statements differ in emphasis: **verify before relying on this** in each target runtime.

### Step 7: Clean up listeners

Sources: MDN, AbortSignal; Node.js issue 46525. Fetched 5 November 2026.

| Fact | Consequence |
|---|---|
| `{ once: true }` removes the listener only when the event fires | If the work ends first, the listener stays. Remove it in `finally` with a named function |
| `fetch()` does not clean up listeners your code added | Your code owns them |
| Signals from `AbortController` can be collected when signal and controller are unreachable. `AbortSignal.any()` and `timeout()` signals are kept alive while pending | Do not create thousands of long timeout signals that nothing awaits |
| Node v19.6.0 printed "11 abort listeners added" when 15 sequential fetches shared one signal that never aborted. A maintainer said the signal was never aborted, so listeners were not removed yet. The reporter disputed that, and the thread records no fix | A long-lived signal passed to many operations can trigger `MaxListenersExceededWarning`. Use a fresh controller per batch. Do not raise the limit as the fix |

### Step 8: Verify and report

1. Re-run the scanner. Errors must be 0. Each warning is fixed or listed with a reason.
2. Run the project's tests with the user's command. Report pass and fail counts, not output.
3. For each abortable function, add or confirm a test that passes an already-aborted signal (`AbortSignal.abort()`) and expects a rejection with `AbortError`, and one that aborts mid-flight (`references/patterns.md`, section 5).
4. State what is untested: server-side effects of cancelled writes, behaviour of third-party libraries with signals, low-memory behaviour.
5. Use the report template. Do not echo unchanged code back.

## Worked example

An invented file, `search.jsx`, 41 lines: a module-level `AbortController` shared by every call, a `setTimeout` that aborts it with no `clearTimeout`, a `useEffect` that starts a request with no cleanup, an abort listener with no `once`, and a loop over URLs that takes a `signal` and never checks it.

```
$ python3 scripts/abort_check.py search.jsx
0 errors, 5 warnings, 1 info, 1 files
search.jsx:3 warn  controller-module-scope  module-level controller 'controller' is single use ...
search.jsx:10 warn  timeout-manual           setTimeout that calls abort() and no clearTimeout in the file ...
search.jsx:12 info  signal-not-checked       a function takes a signal and loops with await ...
search.jsx:22 warn  effect-fetch-no-abort    useEffect starts a request with no abort in cleanup ...
search.jsx:23 warn  fetch-no-signal          fetch() with no signal: it cannot be cancelled or time out ...
search.jsx:31 warn  listener-no-once         abort listener without { once: true } ...
```

(Messages shortened here.) The agent asks for the timeout and is told 5,000 ms for search. `getJson` takes `{ signal }` and uses `AbortSignal.any([AbortSignal.timeout(5000), signal])`, and its catch returns `null` for `AbortError` and throws a clear message for `TimeoutError`. The shared controller is deleted. The effect creates a controller per run, passes its signal and aborts in cleanup, with a catch that ignores `AbortError`. The listener gets `{ once: true }` and a returned remover. The loop calls `signal.throwIfAborted()` at the top of each iteration.

```
$ python3 scripts/abort_check.py search.jsx
0 errors, 0 warnings, 0 info, 1 files
```

The scanner passing shows that the patterns it knows are gone. It does not show that the server stops work or that the supported browsers include `AbortSignal.any`. Both go in the report as untested.

## Templates

### Report

```
Cancellation report: <repo or folder>, <date>
Files: <count>. Runtime: <Node version / browsers>. Tests: <command>.
abort_check before: N errors, N warnings, N info. After: N errors, N warnings, N info.
Changes: <file:line  code  old -> new>   (one line each)
Timeouts set (from the user): <operation  ms>
Writes made cancellable (user approved): <operation  behaviour on cancel>
Left as is: <file:line  reason>
Untested: server-side effects of cancelled writes, third-party signal support, browser support for any() (edit to match)
```

### Starting prompt

```
Use the abortcontroller-cancellation skill. Scope: <paths>. Runtime: <Node N / browsers>.
Symptom: <stale results | work after leaving | timeout leaves work running | listener warning>.
Timeouts in ms: <operation: value>. Calls that write data: <list>. Tests: <command>.
Edit on a branch in batches of one file and show each diff first.
```

### Abortable call with timeout and user cancel

```js
export async function getJson(url, { signal, timeoutMs } = {}) {
  const signals = [AbortSignal.timeout(timeoutMs), ...(signal ? [signal] : [])];
  try {
    const res = await fetch(url, { signal: AbortSignal.any(signals) });
    return await res.json();
  } catch (err) {
    if (err.name === "AbortError") return null;
    if (err.name === "TimeoutError") throw new Error("request timed out");
    throw err;
  }
}
```

`timeoutMs` is required from the caller: there is no default.

## Failure modes and anti-patterns

| Mistake | How to spot it | Do instead |
|---|---|---|
| One controller shared and reused | `controller-module-scope`; every later request fails at once after one abort | One controller per operation (step 1) |
| A timer that aborts but is never cleared, or a race that leaves the work running | `timeout-manual`, or `race-timeout` from `async_check.py` | `AbortSignal.timeout(ms)` passed to the work (step 3) |
| Checking only `AbortError` | `abort-name-only`; timeouts appear as generic failures | Handle both names (step 6) |
| Abort listeners that pile up | `listener-no-once`, `MaxListenersExceededWarning` | `once: true` plus removal in `finally`; fresh controller per batch (step 7) |
| A fetch in an effect with no abort | `effect-fetch-no-abort`; stale rows appear | Controller per run, abort in cleanup (step 5) |

## Quality bar

Run every item before handing over.

1. `abort_check.py` errors are 0, and every warning and info line is fixed or listed with a reason.
2. Every timeout value in the code came from the user, and the report lists each one.
3. No controller is shared across unrelated operations or created at module level.
4. Every catch that can see an abort checks for both `AbortError` and `TimeoutError`.
5. Every abort listener is removed on the normal path as well as the abort path.
6. Each abortable function has a test with an already-aborted signal and one with a mid-flight abort, or the report says why not.
7. No write was made cancellable without the user's approval of the half-sent behaviour.
8. The report lists what was not tested, including server-side effects and browser support for newer methods.

## Cost

Figures are estimates from this skill's own count (words × 1.35 for prose, characters ÷ 3.5 for code and tables), not from a model tokeniser. Rival figures are estimates from the visible text of pages I read.

| Item | Tokens | Status | Read condition |
|---|---|---|---|
| A. Description (always loaded) | 89 | Estimate | Every session with the skill installed |
| B. `SKILL.md` body | 4,886 | Estimate | When the skill fires |
| C. `references/patterns.md` | 1,099 | Estimate | Step 3 for the manual link to a parent signal, step 4 for the abortable sleep, step 5 for effect code, step 8 for tests |
| C. `references/appendix.md` | 848 | Estimate | Only for availability notes, the behaviour table or the rival comparison |
| Script output (typical run) | 312 | Estimate | Scanner output measured on the worked example (1,092 characters) |
| D. Typical run (A + B + script output) | 5,287 | Estimate | Common path, no references |
| D. Worst case (A + B + both references + 40 lines of output) | 7,922 | Estimate | Large input, every branch |
| E. Cheaper path | 5,287 | Estimate | Run the scanner, fix only flagged lines, skip both references and the tests step |

The four rivals I read have bodies of about 365 to 880 tokens, or an unreadable body (estimates from visible text). Only one has any cancellation code, a single fetch timeout example. This skill costs more to load, and in return the scanner finds the patterns so the agent does not read every file to look for them.

## Handoffs

`promises-async-await.md` covers combinators, unhandled rejections and `forEach(async)`. Both skills read the same MDN and DOM standard pages, so neither repeats the other.

## Appendix

Reusable code is in `references/patterns.md`. Availability notes and the rival comparison are in `references/appendix.md`. Read them only when a step points there.

## Sources

Last verified 5 November 2026 for every source.

- Anthropic, Skill authoring best practices: https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices
- MDN, AbortSignal: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal
- MDN, Promise: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
- WHATWG, DOM Standard, Aborting ongoing activities: https://dom.spec.whatwg.org/#aborting-ongoing-activities
- Jake Archibald, Abortable fetch (Chrome for Developers, 28 September 2017): https://developer.chrome.com/blog/abortable-fetch
- web-platform-dx, Web features release notes, March 2024: https://web-platform-dx.github.io/web-features-explorer/release-notes/march-2024
- Node.js issue 46525, MaxListenersExceededWarning with a shared AbortSignal: https://github.com/nodejs/node/issues/46525
- pluginagentmarketplace, asynchronous. Rival implementation: https://skills.cat/skills/pluginagentmarketplace/custom-plugin-javascript/asynchronous
- rmyndharis, javascript-pro. Rival implementation: https://playbooks.com/skills/rmyndharis/antigravity-skills/javascript-pro
- jeffallan, javascript-pro. Rival implementation: https://skills.browseract.com/skills/jeffallan_claude-skills_javascript-pro
- vishalsachdev, resilient-async-operations. Rival implementation, page metadata only: https://agent-skills.md/skills/vishalsachdev/claude-skills/resilient-async-operations

---
Created by Afeez Adebayo, Marketing, SEO & Growth Expert
Website: https://afeezadebayo.com
LinkedIn: https://www.linkedin.com/in/afeez-adebayo/
X: https://x.com/za_icon
Source: https://afeezadebayo.com/skills-md/frontend-development/javascript/abortcontroller-cancellation/

Licence: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). You may copy, share and adapt this skill, including for commercial use, as long as you credit Afeez Adebayo and link to the source URL above. Keep this notice in any copy or adapted version.
