Promises without the plumbing
Async functions, available by default in Chrome, Edge, Firefox, and Safari, let you write promise-based code in a style that reads like synchronous JavaScript — without ever blocking the main thread. The syntax is simple: prefix a function with async, and inside it you can await any promise. Execution pauses non-blockingly until that promise settles; a fulfilled promise yields its value, a rejected one throws.
Say you want to fetch a URL and log the response text. The promise-only version chains callbacks:
function logFetch(url) { return fetch(url) .then((response) => response.text()) .then((text) => { console.log(text); }) .catch((err) => { console.error('fetch failed', err); }); }
With async functions, the same logic loses all the callback nesting:
async function logFetch(url) { try { const response = await fetch(url); console.log(await response.text()); } catch (err) { console.log('fetch failed', err); } }
Same line count, considerably less plumbing.
Return values are always promises
An async function always returns a promise, whether or not you use await inside it. Whatever the function returns becomes the promise's fulfillment value; whatever it throws becomes the rejection reason. So hello() below returns a promise that fulfills with "world":
// wait ms milliseconds function wait(ms) { return new Promise((r) => setTimeout(r, ms)); } async function hello() { await wait(500); return 'world'; }
And foo() returns a promise that rejects with Error('bar'):
async function foo() { await wait(500); throw Error('bar'); }
The payoff: handling streams
The readability gain grows with complexity. Consider streaming a response, logging each chunk, and returning the total size. With promises, you end up with a recursive pattern that takes time to untangle:
function getResponseSize(url) { return fetch(url).then((response) => { const reader = response.body.getReader(); let total = 0; return reader.read().then(function processResult(result) { if (result.done) return total; const value = result.value; total += value.length; console.log('Received chunk', value); return reader.read().then(processResult); }); }); }
The async version replaces that cleverness with a plain while-loop:
async function getResponseSize(url) { const response = await fetch(url); const reader = response.body.getReader(); let result = await reader.read(); let total = 0; while (!result.done) { const value = result.value; total += value.length; console.log('Received chunk', value); // get the next result result = await reader.read(); } return total; }
Far easier to follow. Future async iterators may replace the loop with an even cleaner for-of construct.
Syntax variations
The async keyword composes with the usual function forms:
// map some URLs to json-promises const jsonPromises = urls.map(async (url) => { const response = await fetch(url); return response.json(); });
const storage = { async getAvatar(name) { const cache = await caches.open('avatars'); return cache.match(`/avatars/${name}.jpg`); } }; storage.getAvatar('jaffathecake').then(…);
class Storage { constructor() { this.cachePromise = caches.open('avatars'); } async getAvatar(name) { const cache = await this.cachePromise; return cache.match(`/avatars/${name}.jpg`); } } const storage = new Storage(); storage.getAvatar('jaffathecake').then(…);
Watch out for sequential traps
Code that looks synchronous can lure you into needless serialization. This version takes 1000ms to finish because the two awaits run one after the other:
async function series() { await wait(500); // Wait 500ms… await wait(500); // …then wait another 500ms. return 'done!'; }
Starting both operations before awaiting either cuts that to 500ms:
async function parallel() { const wait1 = wait(500); // Start a 500ms timer asynchronously… const wait2 = wait(500); // …meaning this timer happens in parallel. await Promise.all([wait1, wait2]); // Wait for both timers in parallel. return 'done!'; }
Example: fetching in order, but in parallel
Fetching a series of URLs and logging them as soon as possible in the correct order is awkward with pure promises — it invites an intricate reduce-based chain. Converting that to an async function naively produces an overly sequential version where no fetch starts until the previous completes.
The right middle ground keeps fetches parallel and still clears out the reduce cleverness:
function markHandled(...promises) {
Promise.allSettled(promises);
}
async function logInOrder(urls) {
// fetch all the URLs in parallel
const textPromises = urls.map(async (url) => {
const response = await fetch(url);
return response.text();
});
markHandled(...textPromises);
// log them in sequence
for (const textPromise of textPromises) {
console.log(await textPromise);
}
}
All URLs are fetched and read concurrently, and the boring for-loop preserves the required output order.
Running async functions in older browsers
Where generators are supported, Babel can transpile async functions; the transformation is part of its es2017 preset. A standalone alternative exists: instead of writing async/await, you pass a generator to a provided createAsyncFunction helper and use yield in place of await. For legacy browsers without generators, Babel's regenerator runtime can push support as far back as IE8, at the cost of noticeably heavier output.



