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-levelawait(ES2022), which works only inside ES modules - No language change simplified error handling across multiple
awaits. A singletry/catchalways worked - No syntax change made
Promise.alleasier 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-levelawaitis the nearest thing, and it is ES modules only Promise.withResolvers()(ES2024) exposesresolveoutside the constructor—ideal for awaiting eventsawaitin a loop is sequential. UsePromise.allwhen the work is independentPromise.allfails wholesale; useallSettledwhen partial results are fineforEach,mapandfilterdo not await anasynccallback.filtersilently filters nothing- Inside
try, usereturn await—a barereturnescapes thecatch - 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.