cheat sheet
Async / Await
async/await syntax, error handling, parallel execution with Promise.all, sequential vs parallel loops, top-level await, AbortController, and common mistakes.
Async / Await
What it is
async/await is syntactic sugar over Promises introduced in ES2017 (ES8). It lets you write asynchronous code that reads top-to-bottom like synchronous code, while still being non-blocking under the hood.
An async function always returns a Promise. Inside it, await suspends execution of that function — yielding control back to the event loop — until the awaited Promise settles, then resumes with the resolved value.
Basic syntax
Mark a function with async to make it return a Promise automatically; use await inside it to pause execution until a Promise settles. Both the function declaration form and arrow functions support async.
// async function declaration
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
return user; // automatically wrapped in Promise.resolve()
}
// async arrow function
const fetchUser = async (id) => {
const response = await fetch(`/api/users/${id}`);
return response.json();
};
// async function expression
const fetchUser = async function (id) {
const response = await fetch(`/api/users/${id}`);
return response.json();
};
Calling an async function:
// Must await the result or chain .then()
const user = await fetchUser(42);
console.log(user.name);
// Or with .then() (same Promise underneath)
fetchUser(42).then(user => console.log(user.name));
Return value
An async function always returns a Promise, regardless of what you return inside it:
async function one() { return 1; }
one(); // Promise { 1 }
await one(); // 1
Returning another Promise from an async function does not double-wrap it — JavaScript unwraps (assimilates) it:
async function fetchData() {
return fetch('/api/data'); // returns a Promise<Response>, not Promise<Promise<Response>>
}
Error handling
try / catch / finally
Use try/catch inside an async function to handle both synchronous throws and rejected await expressions in one block. finally always runs — useful for cleanup regardless of success or failure.
async function loadConfig(path) {
try {
const text = await fs.promises.readFile(path, 'utf8');
return JSON.parse(text);
} catch (error) {
if (error.code === 'ENOENT') {
console.warn('Config file not found, using defaults');
return {};
}
throw error; // re-throw errors you can't handle here
} finally {
console.log('loadConfig finished'); // always runs, even if error is re-thrown
}
}
Output (file missing):
Config file not found, using defaults
loadConfig finished
Catching without try/catch — .catch() on the call site
const config = await loadConfig('./settings.json').catch(() => ({}));
Re-throw with context
async function getUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
} catch (err) {
throw new Error(`getUser(${id}) failed: ${err.message}`, { cause: err });
}
}
Parallel execution
Common anti-pattern: sequential awaits when order doesn't matter
// SLOW — runs one at a time, each waits for the previous to finish
async function loadDashboard(userId) {
const user = await fetchUser(userId); // ~200ms
const posts = await fetchPosts(userId); // ~150ms
const friends = await fetchFriends(userId); // ~120ms
return { user, posts, friends }; // total: ~470ms
}
Promise.all — run in parallel, fail fast
Accepts an array of Promises and resolves with an array of their values once every one fulfills. If any single Promise rejects, the whole Promise.all rejects immediately and the other results are discarded.
// FAST — all three start simultaneously
async function loadDashboard(userId) {
const [user, posts, friends] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchFriends(userId),
]);
return { user, posts, friends }; // total: ~200ms (longest single request)
}
Promise.allrejects immediately if any of its promises rejects. If one fails, you lose the results of all the others. UsePromise.allSettledwhen you need partial results.
Promise.allSettled — run in parallel, collect all results
Like Promise.all but never short-circuits — it always waits for every Promise to settle, then returns an array of { status, value } / { status, reason } descriptors. Use it when partial success is acceptable.
async function loadDashboardSafe(userId) {
const results = await Promise.allSettled([
fetchUser(userId),
fetchPosts(userId),
fetchFriends(userId),
]);
const [userResult, postsResult, friendsResult] = results;
return {
user: userResult.status === 'fulfilled' ? userResult.value : null,
posts: postsResult.status === 'fulfilled' ? postsResult.value : [],
friends: friendsResult.status === 'fulfilled' ? friendsResult.value : [],
};
}
Output (postsResult rejects):
{ user: { id: 1, name: 'Jay' }, posts: [], friends: [{ id: 2, name: 'Alex' }] }
Promise.race — first one wins
Resolves or rejects with the outcome of whichever Promise settles first, regardless of whether it fulfilled or rejected. The canonical use case is pairing a real request with a timeout Promise.
// Timeout pattern
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)
);
return Promise.race([promise, timeout]);
}
const user = await withTimeout(fetchUser(42), 3000);
Promise.any — first fulfillment wins
Resolves with the first Promise that fulfills; rejected Promises are ignored unless all of them reject, in which case it throws an AggregateError. Use it to race redundant endpoints and take the fastest successful response.
// Try multiple mirrors; use whichever responds first
const data = await Promise.any([
fetch('https://mirror-1.example.com/data'),
fetch('https://mirror-2.example.com/data'),
fetch('https://mirror-3.example.com/data'),
]);
await in loops
Sequential: for...of with await
const userIds = [1, 2, 3, 4, 5];
// Processes one at a time — use when order matters or rate-limiting is needed
for (const id of userIds) {
const user = await fetchUser(id);
console.log(user.name);
}
Output:
Alice
Bob
Carol
Dave
Eve
Parallel: Promise.all + Array.map
// Fires all requests simultaneously
const users = await Promise.all(userIds.map(id => fetchUser(id)));
users.forEach(u => console.log(u.name));
Parallel with concurrency limit
// Process in batches of N to avoid overwhelming the server
async function batchProcess(items, batchSize, fn) {
const results = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(fn));
results.push(...batchResults);
}
return results;
}
const users = await batchProcess(userIds, 3, fetchUser);
Anti-pattern: await inside Array.forEach
// WRONG — forEach does not await; all iterations start but are not awaited
userIds.forEach(async (id) => {
const user = await fetchUser(id); // this await is inside an async callback
console.log(user.name); // may print out of order or after script ends
});
// execution continues here immediately, before any fetch completes
Use for...of or Promise.all + map instead.
Top-level await (ESM only)
In ES modules (.mjs or "type": "module") you can await at the top level of a file — outside any function:
// config.mjs
const response = await fetch('https://api.example.com/config');
export const config = await response.json();
// db.mjs
import pg from 'pg';
export const pool = await new pg.Pool({ connectionString: process.env.DATABASE_URL }).connect();
This is not available in CommonJS. If you use top-level
awaitin a.jsfile, Node.js must determine it is ESM (via"type": "module"inpackage.jsonor the.mjsextension).
Async IIFE pattern
When you need await at the top level of a CJS script (no top-level await available), wrap the entry point in an immediately-invoked async function:
// CJS script — top-level await unavailable
(async () => {
const config = await loadConfig();
const db = await connectDatabase(config);
await startServer(db);
})().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});
Async class methods
Any method on a class can be async. There is no async getter or setter (because getters are synchronous by definition).
class UserService {
async getUser(id) {
return fetch(`/api/users/${id}`).then(r => r.json());
}
async createUser(data) {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error(`Create failed: ${res.status}`);
return res.json();
}
}
const svc = new UserService();
const user = await svc.getUser(1);
Async getter workaround
Async getters are not a language feature. Use an async method named getXxx() or initialize via a factory:
class Config {
// NOT: async get value() { ... } — SyntaxError
// Instead, use a static async factory
static async create() {
const instance = new Config();
instance._data = await loadData();
return instance;
}
get value() { return this._data; } // synchronous after init
}
const config = await Config.create();
console.log(config.value);
AbortController — cancelling async operations
AbortController provides a standard way to cancel async operations (fetch calls, streaming, timers) before they complete.
const controller = new AbortController();
const { signal } = controller;
// Cancel after 5 seconds
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch('/api/large-file', { signal });
const data = await response.json();
clearTimeout(timeoutId);
return data;
} catch (err) {
if (err.name === 'AbortError') {
console.log('Request was cancelled');
return null;
}
throw err;
}
Attach the same signal to multiple operations to cancel them together:
async function fetchBoth(signal) {
const [a, b] = await Promise.all([
fetch('/api/a', { signal }),
fetch('/api/b', { signal }),
]);
return { a: await a.json(), b: await b.json() };
}
const controller = new AbortController();
setTimeout(() => controller.abort(), 2000);
const result = await fetchBoth(controller.signal);
Common mistakes
Forgetting await
// WRONG — returns a Promise object, not the user
async function broken() {
const user = fetchUser(1); // missing await
return user.name; // TypeError: Cannot read properties of a Promise
}
// CORRECT
async function fixed() {
const user = await fetchUser(1);
return user.name;
}
Unhandled rejections
// WRONG — the rejection is unhandled; Node will log a warning and may exit
async function fire() {
const result = await mightFail();
}
fire(); // Promise is floating — nobody catches its rejection
// CORRECT — always handle the rejection at the call site
fire().catch(err => console.error(err));
// Or await it in another async function with try/catch
try {
await fire();
} catch (err) {
console.error(err);
}
Sequential awaits when parallel would be faster
Already covered above — the most impactful performance mistake in async code. Default to Promise.all when results are independent.
await on a non-Promise value
This is harmless but unnecessary:
const x = await 42; // x is 42 — works but pointless
const y = await null; // y is null
const z = await someSync(); // z is the return value — no harm, no benefit
Async functions in setTimeout lose uncaught errors
// WRONG — async errors inside setTimeout are silently swallowed
setTimeout(async () => {
await mightFail(); // rejection is lost
}, 1000);
// CORRECT — catch explicitly
setTimeout(() => {
mightFail().catch(err => console.error('background task failed:', err));
}, 1000);
Mental model — what await actually does
An async function is a state machine generated by the engine. Each await is a point at which the function pauses, hands control back to the event loop, and registers a microtask to resume execution when the awaited Promise settles. While paused, other code runs — the engine is not blocked.
async function example() {
console.log("A");
const v = await Promise.resolve(1); // pauses here, resumes as microtask
console.log("B", v);
return v + 1;
}
console.log("1");
const p = example(); // logs "A" synchronously, returns a Promise
console.log("2");
console.log("3", await p); // logs "B 1" then "3 2"
Output:
1
A
2
B 1
3 2
Two key consequences:
- Code before the first
awaitruns synchronously during the initial call. Putting validation or fast-fail checks above the firstawaitlets them throw before any work starts. - Code after
awaitruns as a microtask, after the current synchronous frame unwinds. State that was true at the start of the function may have changed by the time the next line executes.
Awaiting a non-Promise
await x first coerces x to a Promise via Promise.resolve(x). If x is already a Promise it is reused; otherwise await yields one microtask and resumes with x. This is why const y = await 42 "works" — it costs one microtask boundary for nothing.
async function pointlessAwait() {
return await 42; // ≡ return 42, but adds one microtask
}
The linter rule no-return-await exists for this reason: return await x in tail position is functionally equivalent to return x for the caller but adds an extra microtask. Exception: inside try/catch, return await is correct because dropping the await would let the rejection escape the try block.
// Correct use of return await — keeps catch in scope
async function safe() {
try {
return await mightReject();
} catch (err) {
return fallback();
}
}
// WITHOUT await, rejection would skip the catch
async function broken() {
try {
return mightReject(); // catch is no longer in the call stack when reject fires
} catch (err) {
return fallback(); // never runs
}
}
Async iteration — for await...of
for await...of consumes an async iterable — any object implementing [Symbol.asyncIterator](). Each iteration awaits the iterator's next value, pausing the loop until it settles. It is the idiomatic way to consume Node streams, paginated APIs, and any cursor-like source.
async function* asyncRange(n) {
for (let i = 0; i < n; i++) {
await new Promise((r) => setTimeout(r, 10)); // simulate I/O
yield i;
}
}
for await (const v of asyncRange(3)) {
console.log(v);
}
Output:
0
1
2
Consuming a Node Readable stream
Every Readable stream is an async iterable since Node 10. Reading a file chunk-by-chunk is one for await away.
import { createReadStream } from 'node:fs';
for await (const chunk of createReadStream('input.log', { encoding: 'utf8' })) {
process.stdout.write(chunk);
}
Output:
(contents of input.log)
The pattern carries the backpressure semantics of the stream — the loop pauses while the runtime is full, so memory stays bounded regardless of file size. See node-streams for the full picture.
Paginated APIs as async iterables
The classic shape: an async generator that yields each page and stops when the cursor runs out. Consumers see a flat iterable.
async function* paginate(url) {
let cursor = null;
do {
const res = await fetch(`${url}?cursor=${cursor ?? ''}`);
const page = await res.json();
for (const item of page.items) yield item;
cursor = page.nextCursor;
} while (cursor);
}
let count = 0;
for await (const user of paginate('/api/users')) {
count++;
if (count >= 100) break; // early stop closes the iterator
}
console.log(`processed ${count} users`);
Output:
processed 100 users
When the loop breaks, the async iterator's return() is called automatically, which runs any pending finally block in the generator — letting you close cursors and free resources cleanly.
Async iteration with concurrency
for await is strictly sequential. To process pages in parallel, decouple paging from work: produce a stream of items with the generator, then fan out a fixed-size pool of workers.
async function processWithConcurrency(asyncIter, limit, worker) {
const inflight = new Set();
for await (const item of asyncIter) {
const p = worker(item).finally(() => inflight.delete(p));
inflight.add(p);
if (inflight.size >= limit) {
await Promise.race(inflight);
}
}
await Promise.all(inflight);
}
await processWithConcurrency(paginate('/api/users'), 5, async (user) => {
console.log(user.id);
});
Async generators — async function*
An async generator function (async function*) returns an async iterator that supports both yield (produce a value) and await (consume a Promise). It is the natural shape for any data source that is both async and finite-or-streaming.
async function* lines(stream) {
let buffer = '';
for await (const chunk of stream) {
buffer += chunk;
let nl;
while ((nl = buffer.indexOf('\n')) >= 0) {
yield buffer.slice(0, nl);
buffer = buffer.slice(nl + 1);
}
}
if (buffer) yield buffer;
}
Generator-side semantics
yield xpauses the generator until the consumer callsnext()again.yield somePromiseyields the Promise itself, not its resolved value. To yield the resolved value, writeyield await somePromise.- A
returnfrom inside the generator becomes the iterator's final{ value, done: true }. Subsequentnext()calls return{ value: undefined, done: true }forever. - Throws inside the generator propagate to the consumer's
for awaitas a rejection. - The consumer's
break/returntriggers the generator'sfinallyblocks before disposal.
async function* withCleanup() {
try {
yield 1;
yield 2;
yield 3;
} finally {
console.log('generator cleanup'); // runs on break or normal exit
}
}
for await (const v of withCleanup()) {
if (v === 2) break;
console.log(v);
}
Output:
1
generator cleanup
Parallel composition patterns
The default of await is sequential. Five recipes cover almost every parallelism need.
1. Independent work — Promise.all
When operations don't depend on each other and you need every result.
const [user, posts] = await Promise.all([fetchUser(id), fetchPosts(id)]);
2. Tolerant of failures — Promise.allSettled
When you want every result regardless of which ones failed.
const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()]);
const ok = results.filter((r) => r.status === 'fulfilled').map((r) => r.value);
3. Fastest one wins — Promise.any
When you have redundant sources and need the first successful response.
const data = await Promise.any([fetchPrimary(), fetchSecondary(), fetchTertiary()]);
4. Race with a timeout — Promise.race
When you want a hard deadline regardless of which side wins.
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms)
),
]);
}
5. Bounded pool — N at a time
When you want concurrency but capped at N. Reuse the pool from promises.
async function pool(items, limit, worker) {
const results = new Array(items.length);
let i = 0;
async function next() {
while (i < items.length) {
const idx = i++;
results[idx] = await worker(items[idx], idx);
}
}
await Promise.all(Array.from({ length: limit }, next));
return results;
}
Top-level await — runtime considerations
Top-level await works in ES modules (.mjs, or .js in a "type": "module" package). It blocks the importer, not the event loop — but if every module on the critical path has a await at the top, your application's startup is now serialised through them.
// db.mjs
export const db = await connectDatabase();
// users.mjs
import { db } from './db.mjs'; // pauses until db.mjs settles
export async function getUser(id) { return db.users.findOne({ id }); }
// app.mjs
import { getUser } from './users.mjs'; // pauses until users.mjs (and transitively db.mjs) settles
The dependency graph is evaluated in topological order; cycles with TLA can deadlock. See modules.
Top-level await is unavailable in:
- CJS files (
.cjs, or.jsin a"type": "commonjs"package). - The default global scope of script-tag inclusion (
<script>withouttype="module"). - Inside a
staticblock of a class.
In any of these contexts, wrap the entry point in an async IIFE as documented above.
await using — disposable resources (ES2024 / TC39 stage 4)
The using and await using declarations bind a resource to a block; when the block exits — by return, throw, or fall-through — the resource's Symbol.dispose or Symbol.asyncDispose runs automatically. This is the JS equivalent of Python's with and C#'s using.
class FileHandle {
constructor(path) {
this.path = path;
console.log(`opening ${path}`);
}
async [Symbol.asyncDispose]() {
console.log(`closing ${this.path}`);
}
}
async function run() {
await using f = new FileHandle('./data.txt');
console.log('working with file');
// f is async-disposed when this function returns
}
await run();
Output:
opening ./data.txt
working with file
closing ./data.txt
Multiple await using declarations dispose in reverse order — LIFO, matching how finally blocks unwind. Available in Node 22+, modern Chrome and Firefox; older runtimes need a transpiler (TypeScript 5.2+, Babel @babel/plugin-proposal-explicit-resource-management).
Microtask boundaries inside async functions
Every await is a microtask suspension point. Code that touched shared state can be subtly broken by an await in the middle, because between await x and the next line, other tasks may have mutated that state.
let inFlight = false;
async function clickHandler() {
if (inFlight) return;
inFlight = true;
await doWork(); // microtask boundary — another click can fire here
inFlight = false;
}
If two clicks fire fast enough, the second sees inFlight === true and returns — that's the desired behaviour. The trap is more subtle: the line after await reads inFlight, but cannot reset it to false until the awaited Promise settles, so a third click during doWork() is also rejected. That's usually fine for a debounce, but make the intent explicit:
async function clickHandler() {
if (inFlight) return;
inFlight = true;
try {
await doWork();
} finally {
inFlight = false; // always reset, even on error
}
}
TC39 async-generator suspension model
For deeper understanding, four moments matter inside an async function*:
- First call — calling the generator returns an async iterator immediately. No code in the body has run yet.
next()call — runs the body up to the nextyield(or end). ReturnsPromise<{ value, done }>.yieldresumption — the nextnext()resumes the body with the value the consumer passed in.- Disposal —
return()or thrown errors run any pendingfinallyblocks.
This means the line await something() placed before the first yield does not run until the consumer's first next() — useful if expensive setup should be deferred until iteration actually begins.
Common anti-patterns (extended)
Awaiting in a tight CPU loop
// Awaiting a settled microtask millions of times starves the event loop
async function broken() {
let sum = 0;
for (let i = 0; i < 1_000_000; i++) {
sum += await Promise.resolve(i); // unnecessary microtask per iteration
}
return sum;
}
The awaits don't yield to I/O because microtasks drain before macrotasks. Worse: each iteration allocates a Promise that becomes garbage. Drop the await entirely when there's no real async work.
Implicit await in callback context
// .map's callback is sync; the async function below resolves to an array of Promises
async function broken(items) {
return items.map(async (item) => await transform(item));
}
// Caller probably expected the resolved values — they get pending Promises
const arr = await broken([1, 2, 3]);
console.log(arr[0]); // Promise { <pending> }
Fix with Promise.all:
async function fixed(items) {
return Promise.all(items.map((item) => transform(item)));
}
Forgetting to await inside try
async function leak() {
try {
fetchData(); // missing await — rejection escapes the try
} catch (err) {
console.error("never runs");
}
}
Reading variables across an await without remembering they may have changed
async function update(user) {
const current = userCache.get(user.id);
await persist(user); // await — userCache may have been mutated
current.lastSaved = Date.now(); // current may be stale
}
Re-read state after every await when the value might have been touched by another task.
Real-world recipes
Reading a JSON-Lines file line-by-line
A streaming line iterator + JSON parse, bounded memory regardless of file size.
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
const lines = createInterface({
input: createReadStream('events.jsonl'),
crlfDelay: Infinity,
});
let count = 0;
for await (const line of lines) {
if (!line.trim()) continue;
const event = JSON.parse(line);
if (event.type === 'error') count++;
}
console.log(`${count} errors`);
Output:
247 errors
Background task with cancellation and cleanup
A long-running task that runs on an interval, can be stopped, and cleans up its resources.
async function runWorker(signal) {
while (!signal.aborted) {
try {
const job = await dequeue({ signal });
await processJob(job);
} catch (err) {
if (err.name === 'AbortError') break;
console.error('job failed', err);
}
}
console.log('worker stopped');
}
const controller = new AbortController();
const workerDone = runWorker(controller.signal);
process.on('SIGTERM', () => controller.abort());
await workerDone;
Retry with jitter
Exponential backoff with random jitter to avoid thundering herds.
async function retry(fn, { attempts = 5, base = 250, factor = 2, max = 10_000 } = {}) {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (err) {
if (i >= attempts - 1) throw err;
const delay = Math.min(base * factor ** i, max);
const jittered = delay / 2 + Math.random() * (delay / 2);
await new Promise((r) => setTimeout(r, jittered));
}
}
}
const data = await retry(() => fetch('/api/flaky').then((r) => r.json()));
Map-with-progress
Async map that reports progress as each item finishes.
async function mapWithProgress(items, fn, onProgress) {
let done = 0;
return Promise.all(
items.map(async (item, i) => {
const result = await fn(item, i);
onProgress(++done, items.length);
return result;
})
);
}
await mapWithProgress(
['a', 'b', 'c', 'd'],
async (x) => x.toUpperCase(),
(done, total) => console.log(`${done}/${total}`)
);
Output:
1/4
2/4
3/4
4/4
Composing multiple cancellable steps
Three sequential steps that share a single AbortController. Cancelling the controller stops whichever step is currently in flight.
async function pipeline(signal) {
const a = await fetch('/api/step1', { signal }).then((r) => r.json());
const b = await fetch(`/api/step2/${a.id}`, { signal }).then((r) => r.json());
return fetch('/api/step3', {
method: 'POST',
body: JSON.stringify(b),
signal,
});
}
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
try {
await pipeline(controller.signal);
} catch (err) {
if (err.name === 'AbortError') console.log('pipeline cancelled');
else throw err;
}
Async semaphore
When you need a generic "no more than N concurrent operations" gate that any code can await acquire() against.
function semaphore(limit) {
let active = 0;
const queue = [];
return {
async acquire() {
if (active < limit) {
active++;
return;
}
await new Promise((resolve) => queue.push(resolve));
active++;
},
release() {
active--;
const next = queue.shift();
if (next) next();
},
};
}
const sem = semaphore(3);
async function task(id) {
await sem.acquire();
try {
await doWork(id);
} finally {
sem.release();
}
}
await Promise.all(Array.from({ length: 20 }, (_, i) => task(i)));
Streaming JSON response
Process a fetch response as it arrives without buffering the whole body.
const response = await fetch('/api/large.jsonl');
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
let nl;
while ((nl = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, nl);
buffer = buffer.slice(nl + 1);
if (line) handle(JSON.parse(line));
}
}
Common pitfalls
- Sequential awaits when parallel would work — single most common performance bug in async code. Default to
Promise.allwhen steps are independent. - Missing
awaitinsidetry— the rejection escapes thetryblock. Alwaysawait(orreturn) before thetrycloses. return awaitoutside oftryis redundant — it adds a microtask. Insidetry/catch, it is required; outside, drop it.forEachwith async callbacks —forEachdiscards return values. Usefor...of,for await...of, orPromise.all(arr.map(...)).- Reading state across an
await— the value may have been mutated by another microtask. Re-fetch or capture into a local before the await. - Async getters and setters — not a language feature. Use an async method (
getX()) or a static factory. - Top-level await in CJS —
SyntaxError. Switch to ESM or wrap in an async IIFE. - Awaiting in a synchronous reduce —
arr.reduce((acc, x) => acc + await fn(x))is a parse error in async code (the inner callback isn't async). Usefor...ofor fold over Promises. - Cancellation without
signalpropagation — wrapping fetch in your own timeout race leaves the request running in the background. Passsignalthrough every layer that accepts it. - Async constructor — class constructors cannot be async. Use a static
create()factory that returns a Promise.