Browse by section

Web Design 日本語

async/await in JavaScript: What Actually Changed

The genuinely new additions around asynchronous JavaScript are three: top-level await (ES2022), Promise.withResolvers() (ES2024), and Array.fromAsync() (ES2024).

This article was published in 2024 and completely rewritten in September 2026. Of the four “improvements” listed at the time, three described features that do not exist. Those have been corrected, and the article now covers the syntax that is real and the mistakes people actually make with async/await.

  • There is no “shortcut that lets you get a return value without async“. The nearest real feature is top-level await (ES2022), which works only inside ES modules
  • No language change simplified error handling across multiple awaits. A single try/catch always worked
  • No syntax change made Promise.all easier to combine with

Sponsored

The basics

An async function always returns a promise. await suspends that function until the promise settles.

async function fetchData(url) {
  try {
    const response = await fetch(url);

    // fetch does not reject on 404 or 500
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    console.error('Request failed:', error);
    throw error; // do not swallow it
  }
}

The response.ok check is still required. fetch() only rejects when the network transaction itself fails; a 500 response resolves normally. More on this in practical fetch().

Top-level await: no wrapper function needed

Added in ES2022. await works at the top level of an ES module.

<script type="module" src="main.js"></script>
// main.js (an ES module)
const res = await fetch('/api/config');
const config = await res.json();

// no IIFE required
export { config };

It removes the boilerplate async IIFE.

// the old pattern
(async () => {
  const res = await fetch('/api/config');
  const config = await res.json();
})();

There are real restrictions:

  • ES modules only: <script type="module">, .mjs, or a package with "type": "module"
  • A syntax error in a classic <script> or in CommonJS
  • It blocks modules that import it. Slow work at the top level delays the whole initialisation chain

Sponsored

Promise.withResolvers(): resolve from outside

A small but genuinely useful ES2024 addition. It hands you the promise and its resolve / reject together.

const { promise, resolve, reject } = Promise.withResolvers();

Previously you needed outer variables to escape the constructor.

// the old pattern
let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

It shines when the promise is created in one place and settled in another—waiting for a dialog result, for instance.

function confirmDialog(message) {
  const { promise, resolve } = Promise.withResolvers();

  const dialog = document.getElementById('confirm-dialog');
  dialog.querySelector('.message').textContent = message;

  dialog.addEventListener('close', () => {
    resolve(dialog.returnValue === 'ok');
  }, { once: true });

  dialog.showModal();
  return promise;
}

// the caller just awaits
if (await confirmDialog('Delete this post?')) {
  await deletePost();
}

It is the clean way to turn an event-driven flow into something awaitable. Available in Chrome 119, Firefox 121, Safari 17.4 and Node.js 22.

Sequential versus parallel

This is the most common mistake with async/await. An await inside a loop runs the requests one after another.

// sequential: three items take three times as long
const results = [];
for (const url of urls) {
  results.push(await fetchData(url));
}

When the operations do not depend on each other, start them all and then await.

// parallel: takes as long as the slowest one
const results = await Promise.all(urls.map((url) => fetchData(url)));

That said, sequential is sometimes correct—when each result feeds the next request, or when an API has rate limits. The problem is not the loop; it is doing it unintentionally.

The Promise.all trap

Promise.all() fails entirely if any one promise rejects. To keep rendering when part of the data is unavailable, use Promise.allSettled().

// nothing renders without all of these
const [user, posts] = await Promise.all([
  fetchData('/api/user'),
  fetchData('/api/posts'),
]);

// partial results are acceptable
const results = await Promise.allSettled([
  fetchData('/api/user'),
  fetchData('/api/recommend'), // non-critical
  fetchData('/api/notice'),
]);

for (const r of results) {
  if (r.status === 'fulfilled') render(r.value);
  else console.warn('Failed:', r.reason);
}

When any single success will do, use Promise.any().

Sponsored

await does nothing inside forEach

Another classic. forEach ignores the promise its callback returns, so nothing is awaited.

// 'done' prints first
urls.forEach(async (url) => {
  await fetchData(url);
});
console.log('done');

Use for...of or Promise.all instead.

// sequential
for (const url of urls) {
  await fetchData(url);
}

// parallel
await Promise.all(urls.map((url) => fetchData(url)));

console.log('done');

The same applies to map and filter: an async callback gives you an array of promises. With filter it is worse—a promise is always truthy, so nothing is filtered out at all.

// filters nothing (promises are truthy)
const valid = items.filter(async (item) => await isValid(item));

// resolve the flags first, then filter
const flags = await Promise.all(items.map((item) => isValid(item)));
const valid = items.filter((_, i) => flags[i]);

Async iterators and Array.fromAsync()

For data that arrives in pages, an async generator reads naturally.

async function* fetchAllPages(baseUrl) {
  let page = 1;

  while (true) {
    const res = await fetch(`${baseUrl}?page=${page}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);

    const items = await res.json();
    if (items.length === 0) return;

    yield* items;
    page += 1;
  }
}

// handle each item as it arrives
for await (const item of fetchAllPages('/api/items')) {
  render(item);
}

To collect everything first, ES2024 gives you Array.fromAsync().

const all = await Array.fromAsync(fetchAllPages('/api/items'));
console.log(all.length);

For large result sets, stay with for await. Array.fromAsync() holds everything in memory.

Error handling details

A missing await cannot be caught

// the try/catch is bypassed
try {
  fetchData(url); // ← await missing
} catch (e) {
  // never runs; becomes an unhandled rejection
}

This is also why return await differs from return. With a bare return promise inside try, the rejection happens after the function has exited, so that catch never sees it.

async function a() {
  try {
    return fetchData(url);        // not caught
  } catch (e) { /* never runs */ }
}

async function b() {
  try {
    return await fetchData(url);  // caught
  } catch (e) { /* runs */ }
}

Preserve the original error

When re-throwing, attach the original with cause.

try {
  return await fetchData(url);
} catch (error) {
  throw new Error(`Failed to load user data (${url})`, { cause: error });
}

The receiver can then inspect error.cause. Replacing the message without keeping the cause throws away the information you need to debug.

Timeouts and cancellation

An await on its own waits forever. One line adds a timeout.

try {
  const res = await fetch('/api/data', { signal: AbortSignal.timeout(5000) });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return await res.json();
} catch (err) {
  if (err.name === 'TimeoutError') {
    showMessage('No response from the server');
  } else if (err.name === 'AbortError') {
    // the user cancelled
  } else {
    throw err;
  }
}

Combine it with user cancellation using AbortSignal.any()—see practical fetch().

Support

Feature Added Chrome Firefox Safari
Top-level await ES2022 89 89 15
Promise.withResolvers() ES2024 119 121 17.4
Array.fromAsync() ES2024 121 115 17
AbortSignal.timeout() — Baseline April 2024

Summary

  • There is no “shortcut without async“. Top-level await is the nearest thing, and it is ES modules only
  • Promise.withResolvers() (ES2024) exposes resolve outside the constructor—ideal for awaiting events
  • await in a loop is sequential. Use Promise.all when the work is independent
  • Promise.all fails wholesale; use allSettled when partial results are fine
  • forEach, map and filter do not await an async callback. filter silently filters nothing
  • Inside try, use return await—a bare return escapes the catch
  • Re-throw with { cause: error }
  • Add timeouts with AbortSignal.timeout()

The async/await syntax itself has been stable for years; what changed recently is the surrounding Promise API. When you read that “new syntax was added,” check a compatibility table first.