JavaScript skill 2 of 12
AbortController SKILL.md: Cancel Fetches, Fix Timeouts and Stale Results
The AbortController Cancellation skill makes Claude, Cursor or another AI agent find and fix fetches with no signal, timeouts that never stop the work, reused controllers, leaking abort listeners and stale responses in effects. A scanner reports each pattern by file and line, the agent edits only flagged lines in batches you approve, and the scan is re-run to show the result.
abortcontroller-cancellation/SKILL.md, version 1.0.0, 3,309 words, plus scripts/abort_check.py and two reference files.
By Afeez Adebayo, Marketing, SEO & Growth Expert with 10+ years of experience.
Created 5 November 2026. Updated 5 November 2026. Free to use under CC BY 4.0.
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
- Propose, then change. Report first. Edit only after the user approves a batch, on a branch under version control. Never push, merge or deploy.
- 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.
- Never run the user's code against live services to test a fix. Use the project's tests, mocks or a test environment.
- 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.
- 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.
- Refuses to: invent a timeout value, swallow
AbortErrorandTimeoutErrortogether 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?" |
Raw SKILL.md file
This is the exact file agents read, including the credit header and licence notice. Save it as abortcontroller-cancellation/SKILL.md.
---
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.
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 -60Read 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
- Re-run the scanner. Errors must be 0. Each warning is fixed or listed with a reason.
- Run the project's tests with the user's command. Report pass and fail counts, not output.
- For each abortable function, add or confirm a test that passes an already-aborted signal (
AbortSignal.abort()) and expects a rejection withAbortError, and one that aborts mid-flight (references/patterns.md, section 5). - State what is untested: server-side effects of cancelled writes, behaviour of third-party libraries with signals, low-memory behaviour.
- 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 filesThe 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
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.
abort_check.pyerrors are 0, and every warning and info line is fixed or listed with a reason.- Every timeout value in the code came from the user, and the report lists each one.
- No controller is shared across unrelated operations or created at module level.
- Every catch that can see an abort checks for both
AbortErrorandTimeoutError. - Every abort listener is removed on the normal path as well as the abort path.
- Each abortable function has a test with an already-aborted signal and one with a mid-flight abort, or the report says why not.
- No write was made cancellable without the user's approval of the half-sent behaviour.
- 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
What this skill does
Cancellation code fails quietly. A setTimeout that calls abort() and is never cleared outlives a fast response. A Promise.race against a timer leaves the loser running, which the MDN Promise page says in so many words. One controller shared by every call stops working after the first abort, because MDN states that a signal can only be used once, and a later fetch with it rejects at once.
The skill first lists what must be cancellable and who owns each controller. It then runs scripts/abort_check.py, which reports file, line and code for each pattern, and the agent edits only the flagged lines in batches you approve. It replaces manual timers with AbortSignal.timeout, makes your own async functions accept a signal as the DOM Standard advises, separates AbortError from TimeoutError in every catch, removes abort listeners on both paths, and asks you for every timeout value instead of choosing one.
Who it is for
Use it if you are a frontend or Node developer, a reviewer of AI-generated data fetching code, or the owner of a search box, dashboard or form that shows stale or duplicate results. It fits when work continues after a user leaves, when a timeout fires but the request carries on, or when Node prints MaxListenersExceededWarning for abort listeners. It does not cover promise combinators, debouncing or server-side limits.
What is inside
The file is 3,309 words (version 1.0.0) with one script and two reference files. It holds:
- Six guardrails, including never choosing a timeout value, treating aborted writes as an open question about the server, and never testing against live services.
- Eight inputs the agent must have first, with the exact question for each, including the timeout in milliseconds per request type.
- A workflow of eight steps: ownership, scan, timeouts, abortable functions, stale responses, cancel versus failure, listener cleanup, verify.
- One tool,
scripts/abort_check.py, a standard-library scanner with nine codes that prints file, line and a fixed message but never source text. - A worked example with real output: 5 warnings and 1 info line before, none after.
- A report template, a starting prompt, an abortable call with timeout and user cancel, five failure modes and an eight-item quality bar.
- Two reference files read only when a step points to them: reusable code (abortable sleep, latest-wins, effect cleanup, tests) and availability notes with the rival comparison.
| Item | Tokens | Status | Read when |
|---|---|---|---|
| Description | 89 | Estimate | Every session with the skill installed |
SKILL.md body | 4,886 | Estimate | When the skill fires |
references/patterns.md | 1,099 | Estimate | Steps 3, 4, 5 and 8, for the parent-signal link, abortable sleep, effect code or tests |
references/appendix.md | 848 | Estimate | Only for availability notes, the behaviour table or the rival comparison |
| Typical run | 5,287 | Estimate | Description, body and scanner output |
| Worst case | 7,922 | Estimate | Both references and 40 lines of output |
| Cheaper path | 5,287 | Estimate | Run the scanner, fix flagged lines, skip the references |
The figures come from the skill’s own count (words × 1.35 for prose, characters ÷ 3.5 for code and tables), not from a model tokeniser. The rivals I read have bodies of about 365 to 880 tokens by the same method, and only one has any cancellation code, a single fetch timeout example.
How to install and use it in Claude, Cursor and other agents
Claude. Keep the folder as abortcontroller-cancellation/ with SKILL.md, scripts/ and references/ inside, and zip it. In Claude, open Customize, then Skills, then Upload a skill. In Claude Code, put the folder in ~/.claude/skills/ or .claude/skills/ in a project.
Cursor and other agents. Put the folder where your tool reads skills, or paste the body into its rules and keep the script beside your code. The scanner needs Python 3 and nothing else. Then send the starting prompt from the Templates section.
Before you install. Read the script first. It reads files and writes nothing. The skill tells the agent to edit only on a branch in batches you approve, never to run your code against live services, and never to make a payment, order or email call cancellable without your say-so. Install skills only from sources you trust.
A worked example
An invented 41-line file, search.jsx, shares one module-level controller between all calls, aborts it from a setTimeout that is never cleared, starts a request in useEffect with no cleanup, adds an abort listener without once, and loops over URLs with a signal it never checks. The scanner prints 5 warnings and 1 info line.
The agent asks for the timeout and is told 5,000 ms. It rewrites the helper to use AbortSignal.any with AbortSignal.timeout, handles AbortError and TimeoutError separately, gives the effect its own controller with an abort in cleanup, adds once and a remover to the listener, and calls throwIfAborted in the loop. The re-run prints 0 errors, 0 warnings and 0 info. The report lists server behaviour and browser support for any as untested.
Common mistakes
The skill is built around five ways this goes wrong:
- Reusing one controller. After the first abort every later request fails at once. The skill creates one controller per operation.
- Racing work against a timer. The work keeps running. The skill passes a timeout signal to the work itself.
- Checking only AbortError. A timeout rejects with TimeoutError, so it looks like a generic failure. The skill handles both names.
- Letting abort listeners pile up. MDN says once only removes the listener when the event fires. The skill removes it in finally.
- Fetching in an effect with no abort. A slow old response can overwrite a newer one. The skill aborts in the cleanup.
Related skills
Promises and Async/Await covers combinators, unhandled rejections and forEach(async), and is where race-with-timer findings start. INP Optimisation covers interaction latency in Core Web Vitals. JavaScript SEO covers how JavaScript affects crawling and indexing.
Frequently asked questions
How do I cancel a fetch request in JavaScript?
Create an AbortController, pass its signal in the fetch options and call abort() when you no longer want the answer. The promise rejects with an AbortError, and a pending body read is aborted too, per MDN and the Chrome for Developers article. The skill finds fetches with no signal and adds one per operation.
What is the difference between AbortError and TimeoutError?
AbortError comes from controller.abort() or an already-aborted signal. TimeoutError comes from AbortSignal.timeout() when the delay passes, according to MDN. Treat the first as a cancel and the second as a failure with a message. The skill flags files that check only AbortError.
Can I reuse an AbortController after calling abort()?
No. MDN says a signal can only be used once, and any fetch that receives an aborted signal rejects at once. Create a new controller for each operation. The scanner flags a controller created at module level and aborted somewhere else, which is the usual way this bug appears.
How do I add a timeout to fetch?
Pass AbortSignal.timeout(ms) as the signal. It aborts after the delay with a TimeoutError and needs no timer to clear. To combine it with a user cancel, use AbortSignal.any, which MDN and the web-platform-dx notes place at Baseline newly available since March 2024. The skill asks you for the timeout value.
Why do I get MaxListenersExceededWarning with AbortSignal?
Many operations are adding abort listeners to one long-lived signal that never aborts. In Node issue 46525, 15 sequential fetches on one signal printed an 11-listener warning, and the thread records no fix. Use a fresh controller per batch and remove listeners in finally instead of raising the limit.
How do I stop stale responses overwriting new ones in a React effect?
Create a new AbortController inside the effect, pass its signal to fetch and call abort() in the cleanup, then ignore AbortError in the catch. A flag that only skips the state update does not stop the request. The scanner flags effects that start a request with no abort in cleanup.
How many tokens does the AbortController Cancellation skill use?
The description is about 89 tokens and loads in every session. The SKILL.md body is about 4,886 tokens when the skill fires. A typical run is about 5,287 tokens with scanner output, the cheaper path is the same, and the worst case with both references is about 7,922. All figures are estimates, not from a model tokeniser.
Changelog
- 1.0.0, 5 November 2026: first release.
Use, adapt and share this skill under CC BY 4.0: credit Afeez Adebayo and link to this page.
