skip to content

Async / Await

async/await syntax, error handling, parallel execution with Promise.all, sequential vs parallel loops, top-level await, AbortController, and common mistakes.

21 min read 69 snippets deep dive

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)
}

[!WARNING] Promise.all rejects immediately if any of its promises rejects. If one fails, you lose the results of all the others. Use Promise.allSettled when 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();

[!WARNING] This is not available in CommonJS. If you use top-level await in a .js file, Node.js must determine it is ESM (via "type": "module" in package.json or the .mjs extension).

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 await runs synchronously during the initial call. Putting validation or fast-fail checks above the first await lets them throw before any work starts.
  • Code after await runs 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 x pauses the generator until the consumer calls next() again.
  • yield somePromise yields the Promise itself, not its resolved value. To yield the resolved value, write yield await somePromise.
  • A return from inside the generator becomes the iterator’s final { value, done: true }. Subsequent next() calls return { value: undefined, done: true } forever.
  • Throws inside the generator propagate to the consumer’s for await as a rejection.
  • The consumer’s break/return triggers the generator’s finally blocks 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 .js in a "type": "commonjs" package).
  • The default global scope of script-tag inclusion (<script> without type="module").
  • Inside a static block 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*:

  1. First call — calling the generator returns an async iterator immediately. No code in the body has run yet.
  2. next() call — runs the body up to the next yield (or end). Returns Promise<{ value, done }>.
  3. yield resumption — the next next() resumes the body with the value the consumer passed in.
  4. Disposalreturn() or thrown errors run any pending finally blocks.

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#

  1. Sequential awaits when parallel would work — single most common performance bug in async code. Default to Promise.all when steps are independent.
  2. Missing await inside try — the rejection escapes the try block. Always await (or return) before the try closes.
  3. return await outside of try is redundant — it adds a microtask. Inside try/catch, it is required; outside, drop it.
  4. forEach with async callbacksforEach discards return values. Use for...of, for await...of, or Promise.all(arr.map(...)).
  5. Reading state across an await — the value may have been mutated by another microtask. Re-fetch or capture into a local before the await.
  6. Async getters and setters — not a language feature. Use an async method (getX()) or a static factory.
  7. Top-level await in CJSSyntaxError. Switch to ESM or wrap in an async IIFE.
  8. Awaiting in a synchronous reducearr.reduce((acc, x) => acc + await fn(x)) is a parse error in async code (the inner callback isn’t async). Use for...of or fold over Promises.
  9. Cancellation without signal propagation — wrapping fetch in your own timeout race leaves the request running in the background. Pass signal through every layer that accepts it.
  10. Async constructor — class constructors cannot be async. Use a static create() factory that returns a Promise.