JavaScript skill 1 of 12
Promises and Async/Await SKILL.md: Fix Floating Promises and forEach(async)
The Promises and Async/Await skill makes Claude, Cursor or another AI agent find and fix floating promises, forEach(async) loops, the wrong Promise combinator and swallowed errors in JavaScript and TypeScript. A scanner reports each hazard by file and line, the agent edits only flagged lines in batches you approve, and the scan is re-run to show the result.
promises-async-await/SKILL.md, version 1.0.0, 3,460 words, plus scripts/async_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 async code in this order: decide for each call site whether it must run in sequence, may run in parallel or is meant to be fire-and-forget, run the scanner, choose the right combinator for every group of promises, put error handling where something can act on it, make every rejection reach a handler, then re-run the scanner and the project's tests. The scanner reads text. It cannot know whether a function returns a Promise, so it reports hazards for a person or agent to confirm.
Reusable code is in references/patterns.md. Lint rules, runtime flags and the rival comparison are in references/appendix.md.
When to use this skill, and when not to
Use it when async code runs in the wrong order, a loop finishes before its work does, a rejection kills a Node process or logs as unhandled, a batch fails or half-fails silently, or a review asks for async hygiene.
| Request | Use instead |
|---|---|
| Timeouts, cancelling fetches, stale responses, abort listeners | abortcontroller-cancellation.md |
Typing Promise<T>, generics, satisfies | A TypeScript skill. Out of scope |
| How the event loop and microtasks work, in depth | Read the HTML standard and Jake Archibald's article. Out of scope |
| Data fetching libraries, caching, retries at app level | A state and data fetching skill. Out of scope |
| Worker threads and real parallelism | Out of scope. Promises give concurrency on one thread |
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.
- Behaviour changes are not refactors. Turning a sequential loop into
Promise.allchanges request order, load and failure behaviour. Say so in the report and get approval per file. - Never run the user's code to test a fix against live services. Writes, payments, emails and deletes must not be replayed. Use the project's own tests, mocks or a test environment.
- Data handled. The scanner reads source files and prints file, line, code and a fixed message. It prints no source text, so error messages and request bodies in the code are not copied into the report. Do not paste tokens, API keys, customer data or
.envvalues into prompts or reports. - Nothing is invented. Every rule carries a source and a date. Version-dependent behaviour carries verify before relying on this.
- Refuses to: add
.catch(() => {})to silence a warning, swallow an error to make a test pass, usevoidas a fix without a handler on the promise, disable a lint rule file-wide, 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, not 'the app'." |
| Runtime | "Is this Node, a browser, or both? Which Node major version and which browsers?" |
| Symptom | "What goes wrong: wrong order, early finish, crash, silent failure, slowness?" |
| Limits | "Does any API called here have a rate limit or a concurrent request limit? What is it?" |
| Tests | "What command runs the tests, and is it safe to run on this machine?" |
| Lint | "Is ESLint with type information configured? If not, may I run only the scanner?" |
| 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 promises-async-await/SKILL.md.
---
name: promises-async-await
description: "Audits and fixes promise and async/await code in JavaScript and TypeScript: floating promises, forEach(async), accidental sequential awaits, the wrong Promise.all, allSettled, any or race, swallowed errors, unhandled rejections, async timers and handlers. Runs a scanner, then edits flagged lines only. Use for 'await in forEach', 'unhandled promise rejection', 'Promise.all fails', 'runs in the wrong order' or an async code review. Do NOT use for cancellation or timeouts, event loop theory, or TypeScript typing."
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/promises-async-await/
category: frontend-development
subcategory: javascript
version: "1.0.0"
created: "2026-11-05"
updated: "2026-11-05"
---
# Promises and Async/Await: Find and Fix the Hazards
An agent works on async code in this order: decide for each call site whether it must run in sequence, may run in parallel or is meant to be fire-and-forget, run the scanner, choose the right combinator for every group of promises, put error handling where something can act on it, make every rejection reach a handler, then re-run the scanner and the project's tests. The scanner reads text. It cannot know whether a function returns a Promise, so it reports hazards for a person or agent to confirm.
Reusable code is in `references/patterns.md`. Lint rules, runtime flags and the rival comparison are in `references/appendix.md`.
## When to use this skill, and when not to
Use it when async code runs in the wrong order, a loop finishes before its work does, a rejection kills a Node process or logs as unhandled, a batch fails or half-fails silently, or a review asks for async hygiene.
| Request | Use instead |
|---|---|
| Timeouts, cancelling fetches, stale responses, abort listeners | `abortcontroller-cancellation.md` |
| Typing `Promise<T>`, generics, `satisfies` | A TypeScript skill. Out of scope |
| How the event loop and microtasks work, in depth | Read the HTML standard and Jake Archibald's article. Out of scope |
| Data fetching libraries, caching, retries at app level | A state and data fetching skill. Out of scope |
| Worker threads and real parallelism | Out of scope. Promises give concurrency on one thread |
## 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. **Behaviour changes are not refactors.** Turning a sequential loop into `Promise.all` changes request order, load and failure behaviour. Say so in the report and get approval per file.
3. **Never run the user's code to test a fix against live services.** Writes, payments, emails and deletes must not be replayed. Use the project's own tests, mocks or a test environment.
4. **Data handled.** The scanner reads source files and prints file, line, code and a fixed message. It prints no source text, so error messages and request bodies in the code are not copied into the report. Do not paste tokens, API keys, customer data or `.env` values into prompts or reports.
5. **Nothing is invented.** Every rule carries a source and a date. Version-dependent behaviour carries **verify before relying on this**.
6. **Refuses to:** add `.catch(() => {})` to silence a warning, swallow an error to make a test pass, use `void` as a fix without a handler on the promise, disable a lint rule file-wide, 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, not 'the app'." |
| Runtime | "Is this Node, a browser, or both? Which Node major version and which browsers?" |
| Symptom | "What goes wrong: wrong order, early finish, crash, silent failure, slowness?" |
| Limits | "Does any API called here have a rate limit or a concurrent request limit? What is it?" |
| Tests | "What command runs the tests, and is it safe to run on this machine?" |
| Lint | "Is ESLint with type information configured? If not, may I run only the scanner?" |
| Approval | "May I edit on a branch in batches and show each diff first?" |
## Workflow
### Step 1: Classify each call site
List every group of async calls in scope. Give each one intent before touching it.
| Intent | Signs | Target shape |
|---|---|---|
| Sequential | Later call needs the earlier result; order of side effects matters; payments, writes, rate-limited APIs | `for...of` with `await` |
| Independent | No call uses another's result | `await Promise.all(...)` or `allSettled`, within the limit in step 4 |
| Fire-and-forget | Result not needed, failure must not break the caller | Call with a handler: `.catch(log)` |
| Redundant sources | Same data from several sources, first good answer wins | `Promise.any` |
If intent cannot be read from the code, ask the user. Do not guess for writes, payments or emails.
### Step 2: Run the scanner
```
python3 scripts/async_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 as well as errors. It skips `node_modules`, `dist`, `build` and `.git`.
| Code | Level | Meaning | Fix |
|---|---|---|---|
| `foreach-async` | error | `.forEach(async ...)`: every callback starts, nothing waits | Step 4 |
| `executor-async` | error | `new Promise(async ...)`: a throw inside is lost | Step 5 |
| `floating-then` | warn | `.then()` statement with no `.catch` in the same statement | Step 6 |
| `map-async-statement` | warn | `.map(async ...)` result dropped | Step 3 |
| `all-not-awaited` | warn | `Promise.all(...)` result dropped | Step 3 |
| `race-timeout` | warn | `Promise.race` with a timer: loser keeps running, timer never cleared | `abortcontroller-cancellation.md` |
| `timer-async` | warn | async callback in `setInterval` or `setTimeout` with no `try` nearby | Steps 5 and 6 |
| `empty-catch` | warn | Catch that does nothing | Step 5 |
| `await-in-loop` | info | `await` in a loop body | Step 4 |
| `return-in-try` | info | `return call()` inside `try` | Step 5 |
| `explicit-construction` | info | `new Promise` wrapping a Promise | Step 5 |
The scanner splits on lines and tracks braces, not syntax. A hit inside a string, template or comment is a false positive: read the line. It does not find a missing `await`. For that, use type-aware linting (`references/appendix.md`).
### Step 3: Choose the combinator
Source: MDN, Promise, fetched 5 November 2026.
| Combinator | Fulfils | Rejects | Empty array |
|---|---|---|---|
| `Promise.all` | Array of values when all fulfil | First rejection, at once | Fulfils with `[]` |
| `Promise.allSettled` | Array of `{status, value\|reason}` when all settle | Never | Fulfils with `[]` |
| `Promise.any` | First fulfilled value | `AggregateError` only if all reject | Rejects with `AggregateError` |
| `Promise.race` | Mirrors the first promise to settle | Same | Pending forever |
| Situation | Use |
|---|---|
| Every result is needed and one failure makes the batch useless | `all` |
| Partial results are useful (bulk send, bulk lookup) | `allSettled`, then handle every entry with `status === "rejected"` (step 6) |
| Redundant mirrors or fallbacks | `any` |
| First answer wins and the others can be abandoned | `race` only with a signal that stops the losers (`abortcontroller-cancellation.md`) |
Three rules the MDN page states: `all` and `race` do not cancel the other inputs, their handlers stay attached; a long-pending promise reused in a loop collects handlers that are never removed; a promise starts work when it is created, not when it is awaited. Guard `race` with `if (!items.length)`.
When the scanner reports `map-async-statement` or `all-not-awaited`, wrap the expression in `await Promise.all(...)` or `await Promise.allSettled(...)` according to the table.
### Step 4: Sequential or parallel, and how many at once
| Case | Change |
|---|---|
| `forEach(async ...)` and order matters | `for (const x of xs) { await ... }` |
| `forEach(async ...)` and items are independent | `await Promise.all(xs.map(async (x) => ...))` or `allSettled` |
| `await-in-loop` and items are independent | Parallelise, subject to the limit below |
| `await-in-loop` and an API has a rate limit, or a write must follow a write | Leave sequential. Add a comment saying why |
| Independent items, 10 or fewer, no documented limit | `Promise.all` directly |
| Independent items, more than 10, or a documented limit | Limiter in `references/patterns.md`, section 1 |
Set the limiter's concurrency to the lower of the API's documented concurrent limit and 5. 5 is a starting value, not a measured optimum: tune it against the API and the machine, and say so in the report. Every parallelised loop is a behaviour change (guardrail 2).
A V8 engineering post (12 November 2018) records that `await` on a native promise was cut from three microtask ticks to one in V8 7.2. Do not remove an `await` or add a promise chain for speed.
### Step 5: Put error handling where it can act
| Pattern | Problem | Fix |
|---|---|---|
| `try { return fn(); } catch {}` | A rejection from `fn()` skips the catch | `return await fn()` inside `try` |
| `.catch((e) => { log(e); })` then more `.then` | The chain recovers and carries on with `undefined` | Rethrow if callers must know (MDN) |
| `catch (e) { return null; }` | Error turned into data: the caller cannot tell failure from no result | Rethrow, or return a typed result the caller must check |
| Empty catch | Nothing logged | Log with context and rethrow, or comment why ignoring is safe |
| `new Promise(async (res) => ...)` | A throw inside the async executor is not routed to `reject` | Write an async function and return it |
| `throw` inside a `setTimeout` inside an executor | Not caught by the promise (javascript.info) | Call `reject` from the timer callback |
| `new Promise` around an API that already returns a Promise | Extra layer, easy to lose errors | Return the inner promise |
Place a catch only where code can do something: show a message, retry, fall back, add context and rethrow. A single catch at the top of a request handler is usually enough. `Promise.try()` wraps a function that may throw synchronously or return a promise: MDN does not give its Baseline status in the text read on 5 November 2026, so **verify before relying on this** and prefer `async () => fn()` where support is unknown.
### Step 6: Make every rejection reach a handler
Sources: Node.js, process documentation; typescript-eslint, `no-floating-promises`; javascript.info, error handling with promises. All fetched 5 November 2026.
| Fact | Consequence |
|---|---|
| Node emits `unhandledRejection` when a rejected promise has no handler within a turn of the event loop | A late `.catch` is too late to prevent the event. `rejectionHandled` fires afterwards |
| With no listener, Node raises it as an uncaught exception: stack trace to stderr, exit code 1 | A missing catch can crash a server. The page names `throw` as the default; **verify before relying on this** for the exact version that made it the default |
| Browsers fire `unhandledrejection` on `window` with `promise` and `reason` | Add one global listener that reports to your error tracker |
| `void somePromise()` marks it intentional, but does not handle it | Rejections still go unhandled |
| `.finally()` without `await` is still floating | Chain `.catch` after it |
| An array of promises not passed to `all`, `allSettled`, `any` or `race` is floating | Combine it |
Rules for each case the scanner reports:
1. `floating-then`: add `.catch(handler)` where the failure can be handled, or `await` it, or `return` it.
2. Fire-and-forget: call it as `task().catch((e) => log("task failed", e))`. The handler must log, not stay empty.
3. `allSettled`: after it resolves, filter `status === "rejected"`, log each `reason` with the item it belongs to, and return or throw a summary. Never ignore the array.
4. `timer-async`: put `try/catch` inside the callback. For `setInterval`, add a `running` flag so a slow run cannot overlap the next one.
5. A global `unhandledrejection` or `process.on("unhandledRejection")` listener is for reporting. It is not a replacement for handlers, and Node's page warns that code after an uncaught exception is not safe to resume.
### Step 7: 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 command the user gave. Report pass and fail counts, not output.
3. If type-aware linting exists, run `no-floating-promises` and `no-misused-promises` and report the counts (`references/appendix.md`).
4. For each parallelised loop, state the old and new concurrency and what the user must confirm against API limits.
5. State what is untested: behaviour against live services, ordering under real latency, load.
6. Use the report template. Do not echo unchanged code back.
## Worked example
An invented file, `orders.js`, 39 lines: a `sendAll` that uses `forEach(async ...)` and logs "all receipts sent" straight away, a `loadTotals` loop that awaits one order at a time, a `warm` function with a floating `.then` and a `Promise.race` against a timer, a `safeGet` that returns a promise inside `try`, a `setInterval(async ...)` with no handler and a `.catch(() => {})`.
```
$ python3 scripts/async_check.py orders.js
1 errors, 4 warnings, 2 info, 1 files
orders.js:4 error foreach-async .forEach(async ...) starts every callback and waits for none. ...
orders.js:14 info await-in-loop await inside a loop runs one at a time. ...
orders.js:21 warn floating-then .then() with no .catch(): a rejection is unhandled. ...
orders.js:22 warn race-timeout Promise.race with a timer: the loser keeps running ...
orders.js:27 info return-in-try return of a call inside try: if it returns a Promise ...
orders.js:33 warn timer-async async timer callback: rejection is unhandled ...
orders.js:38 warn empty-catch empty catch swallows the error. ...
```
(Messages shortened here.) The agent classifies `sendAll` as independent with partial results useful, so it uses `allSettled` and reports failures. `loadTotals` is independent and needs every result, so it uses `Promise.all`. `warm` gets a `.catch` on the audit call, and the race is replaced by passing `AbortSignal.timeout(3000)` to the operation, which is the job of `abortcontroller-cancellation.md`. `safeGet` gains `await` and rethrows after logging. The interval gets a `try/catch/finally` and a `running` flag. `swallow` loses its empty catch.
```
$ python3 scripts/async_check.py orders.js
0 errors, 0 warnings, 0 info, 1 files
```
The report names two behaviour changes: `sendAll` now sends all receipts at once (the agent cannot see how many ids callers pass, so it asks the user for the real count and any mail API limit) and `loadTotals` now issues its lookups together. The scanner passing does not show that either change is safe for the API. The user decides.
## Templates
### Report
```
Async report: <repo or folder>, <date>
Files: <count>. Runtime: <Node version / browsers>. Tests: <command>.
async_check before: N errors, N warnings, N info. After: N errors, N warnings, N info.
Type-aware lint (if run): no-floating-promises N -> N, no-misused-promises N -> N.
Changes: <file:line code old -> new> (one line each)
Behaviour changes needing approval: <file old concurrency -> new limit to confirm>
Left as is: <file:line reason>
Untested: live services, real latency, load (edit to match)
```
### Starting prompt
```
Use the promises-async-await skill. Scope: <paths>. Runtime: <Node N / browsers>.
Symptom: <wrong order | early finish | crash | silent failure | slow>.
API limits: <none | N concurrent, N per second>. Tests: <command>.
Edit on a branch in batches of one file and show each diff first.
```
### Partial-failure handler
```js
const results = await Promise.allSettled(items.map((item) => work(item)));
const failures = results
.map((r, i) => (r.status === "rejected" ? { item: items[i].id, reason: r.reason } : null))
.filter(Boolean);
if (failures.length) logger.error("batch partly failed", { failed: failures.length, of: items.length });
return { ok: results.length - failures.length, failures };
```
Log item ids, not item contents: items can hold names, emails or card references.
## Failure modes and anti-patterns
| Mistake | How to spot it | Do instead |
|---|---|---|
| `forEach(async ...)` | `foreach-async`; code after the loop runs first | `for...of` or `Promise.all` with `map` (step 4) |
| `allSettled` with the results ignored or only the fulfilled ones used | The result array is never read for `rejected` | Handle every rejected entry (step 6) |
| Expecting `Promise.all` or `race` to stop the other work | Work continues after the batch rejects | Pass a signal to the work (`abortcontroller-cancellation.md`) |
| Awaiting independent calls one by one | `await-in-loop` and a slow total time | Parallelise within the limit (step 4) |
| A catch that logs and returns `null` | Callers treat missing data as success | Rethrow or return a checked result (step 5) |
## Quality bar
Run every item before handing over.
1. `async_check.py` errors are 0, and every warning and info line is fixed or listed with a reason.
2. No `.forEach(async ...)` remains.
3. Every `allSettled` result is inspected for `rejected` entries.
4. Every fire-and-forget call has a handler that logs.
5. Every parallelised loop states its old and new concurrency, and the limiter value is labelled a starting value.
6. No catch block is empty, and none returns a value that hides the failure.
7. The project's tests were run with the user's command, or the report says they were not.
8. The report lists what was not tested and which changes alter behaviour.
## 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) | 99 | Estimate | Every session with the skill installed |
| B. `SKILL.md` body | 5,045 | Estimate | When the skill fires |
| C. `references/patterns.md` | 1,034 | Estimate | Step 4 for the limiter, step 6 for the fire-and-forget helper and global reporting, or retry and `withResolvers` code |
| C. `references/appendix.md` | 1,006 | Estimate | Only for lint set-up, runtime flags, availability notes or the rival comparison |
| Script output (typical run) | 340 | Estimate | Scanner output measured on the worked example (1,184 characters) |
| D. Typical run (A + B + script output) | 5,484 | Estimate | Common path, no references |
| D. Worst case (A + B + both references + 40 lines of output) | 8,184 | Estimate | Large input, every branch |
| E. Cheaper path | 5,484 | Estimate | Run the scanner, fix only flagged lines, skip both references and type-aware lint |
The three rivals I read have bodies of about 270 to 880 tokens (estimates from visible text). None ships a scanner, and none tells the agent how to choose between `all`, `allSettled`, `any` and `race`. This skill costs more to load, and in return the scanner finds the hazards so the agent does not read every file to look for them.
## Handoffs
`abortcontroller-cancellation.md` covers timeouts, cancelling work and abort listeners. Both skills read the same MDN and DOM standard pages, so neither repeats the other.
## Appendix
Reusable code is in `references/patterns.md`. Lint rules, runtime flags, 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, Promise: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
- Node.js, process, `unhandledRejection` and `rejectionHandled`: https://nodejs.org/api/process.html#event-unhandledrejection
- typescript-eslint, `no-floating-promises`: https://typescript-eslint.io/rules/no-floating-promises/
- javascript.info, Error handling with promises: https://javascript.info/promise-error-handling
- Jake Archibald, Tasks, microtasks, queues and schedules: https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
- V8 blog, Faster async functions and promises (12 November 2018): https://v8.dev/blog/fast-async
- Emily Scott, Why JavaScript forEach does not work with await (DEV Community): https://dev.to/idioms/why-javascript-foreach-does-not-work-with-await-how-to-fix-it-3idj
- jeffallan, javascript-pro. Rival implementation: https://skills.browseract.com/skills/jeffallan_claude-skills_javascript-pro
- pluginagentmarketplace, asynchronous. Rival implementation: https://skills.cat/skills/pluginagentmarketplace/custom-plugin-javascript/asynchronous
- wshobson, modern-javascript-patterns. Rival implementation: https://agent-skills.md/skills/wshobson/agents/modern-javascript-patterns
---
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/promises-async-await/
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: Classify each call site
List every group of async calls in scope. Give each one intent before touching it.
| Intent | Signs | Target shape |
|---|---|---|
| Sequential | Later call needs the earlier result; order of side effects matters; payments, writes, rate-limited APIs | for...of with await |
| Independent | No call uses another's result | await Promise.all(...) or allSettled, within the limit in step 4 |
| Fire-and-forget | Result not needed, failure must not break the caller | Call with a handler: .catch(log) |
| Redundant sources | Same data from several sources, first good answer wins | Promise.any |
If intent cannot be read from the code, ask the user. Do not guess for writes, payments or emails.
Step 2: Run the scanner
python3 scripts/async_check.py src | head -60Read only its output. Do not read the script. The first line has the counts. --strict exits 1 on warnings as well as errors. It skips node_modules, dist, build and .git.
| Code | Level | Meaning | Fix |
|---|---|---|---|
foreach-async | error | .forEach(async ...): every callback starts, nothing waits | Step 4 |
executor-async | error | new Promise(async ...): a throw inside is lost | Step 5 |
floating-then | warn | .then() statement with no .catch in the same statement | Step 6 |
map-async-statement | warn | .map(async ...) result dropped | Step 3 |
all-not-awaited | warn | Promise.all(...) result dropped | Step 3 |
race-timeout | warn | Promise.race with a timer: loser keeps running, timer never cleared | abortcontroller-cancellation.md |
timer-async | warn | async callback in setInterval or setTimeout with no try nearby | Steps 5 and 6 |
empty-catch | warn | Catch that does nothing | Step 5 |
await-in-loop | info | await in a loop body | Step 4 |
return-in-try | info | return call() inside try | Step 5 |
explicit-construction | info | new Promise wrapping a Promise | Step 5 |
The scanner splits on lines and tracks braces, not syntax. A hit inside a string, template or comment is a false positive: read the line. It does not find a missing await. For that, use type-aware linting (references/appendix.md).
Step 3: Choose the combinator
Source: MDN, Promise, fetched 5 November 2026.
| Combinator | Fulfils | Rejects | Empty array | |
|---|---|---|---|---|
Promise.all | Array of values when all fulfil | First rejection, at once | Fulfils with [] | |
Promise.allSettled | Array of `{status, value\ | reason}` when all settle | Never | Fulfils with [] |
Promise.any | First fulfilled value | AggregateError only if all reject | Rejects with AggregateError | |
Promise.race | Mirrors the first promise to settle | Same | Pending forever |
| Situation | Use |
|---|---|
| Every result is needed and one failure makes the batch useless | all |
| Partial results are useful (bulk send, bulk lookup) | allSettled, then handle every entry with status === "rejected" (step 6) |
| Redundant mirrors or fallbacks | any |
| First answer wins and the others can be abandoned | race only with a signal that stops the losers (abortcontroller-cancellation.md) |
Three rules the MDN page states: all and race do not cancel the other inputs, their handlers stay attached; a long-pending promise reused in a loop collects handlers that are never removed; a promise starts work when it is created, not when it is awaited. Guard race with if (!items.length).
When the scanner reports map-async-statement or all-not-awaited, wrap the expression in await Promise.all(...) or await Promise.allSettled(...) according to the table.
Step 4: Sequential or parallel, and how many at once
| Case | Change |
|---|---|
forEach(async ...) and order matters | for (const x of xs) { await ... } |
forEach(async ...) and items are independent | await Promise.all(xs.map(async (x) => ...)) or allSettled |
await-in-loop and items are independent | Parallelise, subject to the limit below |
await-in-loop and an API has a rate limit, or a write must follow a write | Leave sequential. Add a comment saying why |
| Independent items, 10 or fewer, no documented limit | Promise.all directly |
| Independent items, more than 10, or a documented limit | Limiter in references/patterns.md, section 1 |
Set the limiter's concurrency to the lower of the API's documented concurrent limit and 5. 5 is a starting value, not a measured optimum: tune it against the API and the machine, and say so in the report. Every parallelised loop is a behaviour change (guardrail 2).
A V8 engineering post (12 November 2018) records that await on a native promise was cut from three microtask ticks to one in V8 7.2. Do not remove an await or add a promise chain for speed.
Step 5: Put error handling where it can act
| Pattern | Problem | Fix |
|---|---|---|
try { return fn(); } catch {} | A rejection from fn() skips the catch | return await fn() inside try |
.catch((e) => { log(e); }) then more .then | The chain recovers and carries on with undefined | Rethrow if callers must know (MDN) |
catch (e) { return null; } | Error turned into data: the caller cannot tell failure from no result | Rethrow, or return a typed result the caller must check |
| Empty catch | Nothing logged | Log with context and rethrow, or comment why ignoring is safe |
new Promise(async (res) => ...) | A throw inside the async executor is not routed to reject | Write an async function and return it |
throw inside a setTimeout inside an executor | Not caught by the promise (javascript.info) | Call reject from the timer callback |
new Promise around an API that already returns a Promise | Extra layer, easy to lose errors | Return the inner promise |
Place a catch only where code can do something: show a message, retry, fall back, add context and rethrow. A single catch at the top of a request handler is usually enough. Promise.try() wraps a function that may throw synchronously or return a promise: MDN does not give its Baseline status in the text read on 5 November 2026, so verify before relying on this and prefer async () => fn() where support is unknown.
Step 6: Make every rejection reach a handler
Sources: Node.js, process documentation; typescript-eslint, no-floating-promises; javascript.info, error handling with promises. All fetched 5 November 2026.
| Fact | Consequence |
|---|---|
Node emits unhandledRejection when a rejected promise has no handler within a turn of the event loop | A late .catch is too late to prevent the event. rejectionHandled fires afterwards |
| With no listener, Node raises it as an uncaught exception: stack trace to stderr, exit code 1 | A missing catch can crash a server. The page names throw as the default; verify before relying on this for the exact version that made it the default |
Browsers fire unhandledrejection on window with promise and reason | Add one global listener that reports to your error tracker |
void somePromise() marks it intentional, but does not handle it | Rejections still go unhandled |
.finally() without await is still floating | Chain .catch after it |
An array of promises not passed to all, allSettled, any or race is floating | Combine it |
Rules for each case the scanner reports:
floating-then: add.catch(handler)where the failure can be handled, orawaitit, orreturnit.- Fire-and-forget: call it as
task().catch((e) => log("task failed", e)). The handler must log, not stay empty. allSettled: after it resolves, filterstatus === "rejected", log eachreasonwith the item it belongs to, and return or throw a summary. Never ignore the array.timer-async: puttry/catchinside the callback. ForsetInterval, add arunningflag so a slow run cannot overlap the next one.- A global
unhandledrejectionorprocess.on("unhandledRejection")listener is for reporting. It is not a replacement for handlers, and Node's page warns that code after an uncaught exception is not safe to resume.
Step 7: 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 command the user gave. Report pass and fail counts, not output.
- If type-aware linting exists, run
no-floating-promisesandno-misused-promisesand report the counts (references/appendix.md). - For each parallelised loop, state the old and new concurrency and what the user must confirm against API limits.
- State what is untested: behaviour against live services, ordering under real latency, load.
- Use the report template. Do not echo unchanged code back.
Worked example
An invented file, orders.js, 39 lines: a sendAll that uses forEach(async ...) and logs "all receipts sent" straight away, a loadTotals loop that awaits one order at a time, a warm function with a floating .then and a Promise.race against a timer, a safeGet that returns a promise inside try, a setInterval(async ...) with no handler and a .catch(() => {}).
$ python3 scripts/async_check.py orders.js
1 errors, 4 warnings, 2 info, 1 files
orders.js:4 error foreach-async .forEach(async ...) starts every callback and waits for none. ...
orders.js:14 info await-in-loop await inside a loop runs one at a time. ...
orders.js:21 warn floating-then .then() with no .catch(): a rejection is unhandled. ...
orders.js:22 warn race-timeout Promise.race with a timer: the loser keeps running ...
orders.js:27 info return-in-try return of a call inside try: if it returns a Promise ...
orders.js:33 warn timer-async async timer callback: rejection is unhandled ...
orders.js:38 warn empty-catch empty catch swallows the error. ...(Messages shortened here.) The agent classifies sendAll as independent with partial results useful, so it uses allSettled and reports failures. loadTotals is independent and needs every result, so it uses Promise.all. warm gets a .catch on the audit call, and the race is replaced by passing AbortSignal.timeout(3000) to the operation, which is the job of abortcontroller-cancellation.md. safeGet gains await and rethrows after logging. The interval gets a try/catch/finally and a running flag. swallow loses its empty catch.
$ python3 scripts/async_check.py orders.js
0 errors, 0 warnings, 0 info, 1 filesThe report names two behaviour changes: sendAll now sends all receipts at once (the agent cannot see how many ids callers pass, so it asks the user for the real count and any mail API limit) and loadTotals now issues its lookups together. The scanner passing does not show that either change is safe for the API. The user decides.
Templates
Report
Async report: <repo or folder>, <date>
Files: <count>. Runtime: <Node version / browsers>. Tests: <command>.
async_check before: N errors, N warnings, N info. After: N errors, N warnings, N info.
Type-aware lint (if run): no-floating-promises N -> N, no-misused-promises N -> N.
Changes: <file:line code old -> new> (one line each)
Behaviour changes needing approval: <file old concurrency -> new limit to confirm>
Left as is: <file:line reason>
Untested: live services, real latency, load (edit to match)Starting prompt
Use the promises-async-await skill. Scope: <paths>. Runtime: <Node N / browsers>.
Symptom: <wrong order | early finish | crash | silent failure | slow>.
API limits: <none | N concurrent, N per second>. Tests: <command>.
Edit on a branch in batches of one file and show each diff first.Partial-failure handler
const results = await Promise.allSettled(items.map((item) => work(item)));
const failures = results
.map((r, i) => (r.status === "rejected" ? { item: items[i].id, reason: r.reason } : null))
.filter(Boolean);
if (failures.length) logger.error("batch partly failed", { failed: failures.length, of: items.length });
return { ok: results.length - failures.length, failures };Log item ids, not item contents: items can hold names, emails or card references.
Failure modes and anti-patterns
| Mistake | How to spot it | Do instead |
|---|---|---|
forEach(async ...) | foreach-async; code after the loop runs first | for...of or Promise.all with map (step 4) |
allSettled with the results ignored or only the fulfilled ones used | The result array is never read for rejected | Handle every rejected entry (step 6) |
Expecting Promise.all or race to stop the other work | Work continues after the batch rejects | Pass a signal to the work (abortcontroller-cancellation.md) |
| Awaiting independent calls one by one | await-in-loop and a slow total time | Parallelise within the limit (step 4) |
A catch that logs and returns null | Callers treat missing data as success | Rethrow or return a checked result (step 5) |
Quality bar
Run every item before handing over.
async_check.pyerrors are 0, and every warning and info line is fixed or listed with a reason.- No
.forEach(async ...)remains. - Every
allSettledresult is inspected forrejectedentries. - Every fire-and-forget call has a handler that logs.
- Every parallelised loop states its old and new concurrency, and the limiter value is labelled a starting value.
- No catch block is empty, and none returns a value that hides the failure.
- The project's tests were run with the user's command, or the report says they were not.
- The report lists what was not tested and which changes alter behaviour.
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) | 99 | Estimate | Every session with the skill installed |
B. SKILL.md body | 5,045 | Estimate | When the skill fires |
C. references/patterns.md | 1,034 | Estimate | Step 4 for the limiter, step 6 for the fire-and-forget helper and global reporting, or retry and withResolvers code |
C. references/appendix.md | 1,006 | Estimate | Only for lint set-up, runtime flags, availability notes or the rival comparison |
| Script output (typical run) | 340 | Estimate | Scanner output measured on the worked example (1,184 characters) |
| D. Typical run (A + B + script output) | 5,484 | Estimate | Common path, no references |
| D. Worst case (A + B + both references + 40 lines of output) | 8,184 | Estimate | Large input, every branch |
| E. Cheaper path | 5,484 | Estimate | Run the scanner, fix only flagged lines, skip both references and type-aware lint |
The three rivals I read have bodies of about 270 to 880 tokens (estimates from visible text). None ships a scanner, and none tells the agent how to choose between all, allSettled, any and race. This skill costs more to load, and in return the scanner finds the hazards so the agent does not read every file to look for them.
Handoffs
abortcontroller-cancellation.md covers timeouts, cancelling work and abort listeners. Both skills read the same MDN and DOM standard pages, so neither repeats the other.
Appendix
Reusable code is in references/patterns.md. Lint rules, runtime flags, 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, Promise: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
- Node.js, process,
unhandledRejectionandrejectionHandled: https://nodejs.org/api/process.html#event-unhandledrejection - typescript-eslint,
no-floating-promises: https://typescript-eslint.io/rules/no-floating-promises/ - javascript.info, Error handling with promises: https://javascript.info/promise-error-handling
- Jake Archibald, Tasks, microtasks, queues and schedules: https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
- V8 blog, Faster async functions and promises (12 November 2018): https://v8.dev/blog/fast-async
- Emily Scott, Why JavaScript forEach does not work with await (DEV Community): https://dev.to/idioms/why-javascript-foreach-does-not-work-with-await-how-to-fix-it-3idj
- jeffallan, javascript-pro. Rival implementation: https://skills.browseract.com/skills/jeffallan_claude-skills_javascript-pro
- pluginagentmarketplace, asynchronous. Rival implementation: https://skills.cat/skills/pluginagentmarketplace/custom-plugin-javascript/asynchronous
- wshobson, modern-javascript-patterns. Rival implementation: https://agent-skills.md/skills/wshobson/agents/modern-javascript-patterns
What this skill does
Most async bugs are quiet. A forEach(async ...) loop starts every callback and waits for none, so the line after it runs first. A .then() with no .catch() fails nowhere you can see until Node ends the process with exit code 1, according to the Node.js process documentation. Promise.all rejects on the first failure while the other work carries on, a point the MDN Promise page states directly.
The skill first asks what each group of calls is meant to do: run in sequence, run in parallel, run in the background or race for the first answer. It then runs scripts/async_check.py, which reports file, line and code for each hazard, and the agent edits only the flagged lines in batches you approve. It picks between all, allSettled, any and race from a decision table, caps parallel work with a limiter, puts catch blocks where something can act on the error, and re-runs the scanner and your tests.
Who it is for
Use it if you are a frontend or Node developer, a reviewer of AI-generated JavaScript, or a team lead whose service logs unhandled rejections. It fits when a loop finishes before its work, when a batch fails without anyone being told, or when a pull request needs an async review. It does not cover cancellation or timeouts, event loop theory or TypeScript typing.
What is inside
The file is 3,460 words (version 1.0.0) with one script and two reference files. It holds:
- Six guardrails, including editing only on a branch after approval, treating every move from sequential to parallel as a behaviour change, and never replaying writes, payments or emails to test a fix.
- Seven inputs the agent must have first, with the exact question for each.
- A workflow of seven steps: classify intent, scan, choose the combinator, set concurrency, place error handling, make every rejection reach a handler, verify.
- One tool,
scripts/async_check.py, a standard-library scanner with eleven codes that prints file, line and a fixed message but never source text. - A worked example with real output: 1 error, 4 warnings and 2 info lines before, none after.
- A report template, a starting prompt, a partial-failure handler, five failure modes and an eight-item quality bar.
- Two reference files read only when a step points to them: reusable code (limiter, retry, fire-and-forget) and lint, runtime and availability notes.
| Item | Tokens | Status | Read when |
|---|---|---|---|
| Description | 99 | Estimate | Every session with the skill installed |
SKILL.md body | 5,045 | Estimate | When the skill fires |
references/patterns.md | 1,034 | Estimate | Steps 4 and 6, for the limiter, retry or fire-and-forget code |
references/appendix.md | 1,006 | Estimate | Only for lint set-up, runtime flags, availability notes or the rival comparison |
| Typical run | 5,484 | Estimate | Description, body and scanner output |
| Worst case | 8,184 | Estimate | Both references and 40 lines of output |
| Cheaper path | 5,484 | 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 three rivals I read have bodies of about 270 to 880 tokens by the same method, and none ships a scanner or a rule for choosing between the four combinators.
How to install and use it in Claude, Cursor and other agents
Claude. Keep the folder as promises-async-await/ 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 and never to run your code against live services. Install skills only from sources you trust.
A worked example
An invented 39-line file, orders.js, sends receipts with forEach(async ...), awaits order lookups one at a time, leaves a .then() with no catch, races a request against a timer, returns a promise inside try, runs an async setInterval and ends with an empty catch. The scanner prints 1 error, 4 warnings and 2 info lines.
The agent sorts the calls by intent. Receipts become allSettled with failures reported, order lookups become Promise.all, the race becomes a timeout signal, and the interval gets a try/catch/finally and a running flag. The re-run prints 0 errors, 0 warnings and 0 info. The report flags two behaviour changes, because the receipts and lookups now run together, and asks you to confirm the API limits.
Common mistakes
The skill is built around five ways this goes wrong:
- Using forEach with async callbacks. Nothing waits for them. The skill uses
for...oforPromise.allwithmap. - Ignoring the result of allSettled. It never rejects, so failures vanish unless every rejected entry is handled.
- Expecting Promise.all or race to stop the other work. MDN says the other inputs are not cancelled. The skill points to a signal.
- Awaiting independent calls one by one. The scanner flags awaits in loops, and the skill parallelises only within a limit you confirm.
- Catching an error and returning null. The caller cannot tell failure from no result. The skill rethrows or returns a checked result.
Related skills
AbortController Cancellation covers timeouts, cancelling work and abort listeners, which is where the scanner sends race-with-timer findings. INP Optimisation covers interaction latency in Core Web Vitals. JavaScript SEO covers how JavaScript affects crawling and indexing.
Frequently asked questions
Why does await not work inside forEach?
forEach does not pause for promises. It starts every callback and moves on, and it returns undefined, so awaiting the call waits for nothing. Use for…of for one-at-a-time work or await Promise.all with map for parallel work, according to the DEV Community article. The skill finds each case with a scanner.
What is a floating promise?
It is a promise nobody handles: no await, no return and no catch. If it rejects, the error is unhandled. The typescript-eslint no-floating-promises rule flags these, and it says that marking a promise with void does not handle it. The skill scans for the common forms and then points to the lint rule for the rest.
Should I use Promise.all or Promise.allSettled?
Use Promise.all when every result is needed and one failure makes the batch useless. Use allSettled when partial results help, such as a bulk send, and then handle every rejected entry. MDN says all rejects on the first failure and allSettled never rejects. The skill gives a table for the choice, including any and race.
Does Promise.all cancel the other promises when one fails?
No. MDN says the other inputs are not cancelled and their handlers stay attached. The work carries on in the background. To stop it, pass an AbortSignal to the operations. The skill flags a race against a timer for this reason and hands the fix to the AbortController skill.
What happens to an unhandled promise rejection in Node?
Node emits unhandledRejection when a rejected promise has no handler within a turn of the event loop. With no listener it raises an uncaught exception, prints the stack trace and exits with code 1, per the Node.js process documentation. The page names throw as the default mode, and the skill marks the exact version as one to verify.
Does the scanner replace ESLint no-floating-promises?
No. The scanner reads text, so it finds common patterns without type information and cannot tell whether a function returns a promise. The typescript-eslint rule uses types and needs more set-up. The skill runs the scanner first and recommends the lint rule for what a text scan misses, such as a missing await.
How many tokens does the Promises and Async/Await skill use?
The description is about 99 tokens and loads in every session. The SKILL.md body is about 5,045 tokens when the skill fires. A typical run is about 5,484 tokens with scanner output, the cheaper path is the same, and the worst case with both references is about 8,184. 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.
