await: Promises Without the Chain
async and await are syntax built on top of promises. They add no new capability — anything you can write with await you could write with .then — and they make asynchronous code read like ordinary top-to-bottom code, which turns out to be a very large practical difference.
await pauses the function it is written in until the promise settles, then hands you the fulfilled value as an ordinary value. const student = await loadStudent(1) reads exactly like a normal assignment, and the variable really does hold the data rather than a promise wrapping it.
The pause happens only inside that function. The thread is not blocked: while your function waits, the browser carries on handling clicks, rendering the page and running other timers. Under the hood the function is suspended and the remainder of it is scheduled as a callback — which is precisely what .then was doing, written in a way you can read straight down the page.
await is only allowed inside a function marked async, or at the top level of an ES module. Using it anywhere else is a syntax error, and that message — await is only valid in async functions — is among the most common you will meet in this part of the language. The fix is always the same: add async to the function containing it.
function delay(ms, value) {
return new Promise(resolve => setTimeout(() => resolve(value), ms));
}
// With .then
function loadWithThen() {
return delay(100, { name: 'Ananya' })
.then(student => {
return delay(100, [82, 74]).then(marks => {
return `${student.name}: ${marks.join(', ')}`;
});
});
}
// Exactly the same thing with async/await
async function loadWithAwait() {
const student = await delay(100, { name: 'Ananya' });
const marks = await delay(100, [82, 74]);
return `${student.name}: ${marks.join(', ')}`;
}
loadWithAwait().then(text => console.log(text)); // 'Ananya: 82, 74' - The two functions above compile to the same behaviour. Choose
awaitbecause it is easier to read and to debug — a stack trace from anawaitchain points at real line numbers in your function, which a deeply nested.thenchain often does not.
An async Function Always Returns a Promise
Marking a function async changes what it returns. Whatever value you return is wrapped in a promise automatically, so an async function that returns 82 actually returns a promise that fulfils with 82. There is no way to opt out of that.
This is why you cannot escape promises by using async. Calling an async function from ordinary synchronous code gives you a promise, and you must either await it — inside another async function — or attach a .then. Assigning the call to a variable and using it as data is the single most common async bug in beginner code: the variable holds a pending promise, and printing it shows Promise { <pending> } where the data was expected.
A throw inside an async function does not throw to the caller in the usual synchronous way. It rejects the returned promise instead. So the caller catches it with .catch, or with a try/catch placed around an await — never with a try/catch wrapped around the bare call, which will catch nothing at all.
The automatic wrapping also means await works on things that are not promises. await 5 gives you 5, one microtask later. That is genuinely useful when a function might return either a value or a promise depending on whether something was cached, because you never have to check which one you are holding.
async function getMarks() {
return 82; // not a number - a promise fulfilling with 82
}
console.log(getMarks()); // Promise { 82 }
getMarks().then(m => console.log(m)); // 82
async function main() {
const marks = await getMarks();
console.log(marks); // 82 - a real number in here
}
main();
// The classic mistake
const pending = getMarks();
console.log(pending + 10); // '[object Promise]10' - not 92
// throw becomes a rejection
async function boom() {
throw new Error('failed');
}
boom().catch(e => console.log('caught:', e.message));
// await works on plain values too
async function demo() {
console.log(await 5); // 5
}
demo(); - Wrapping a bare call in
try/catch—try { boom(); } catch (e) {}— catches nothing, because the function returned a promise successfully and only rejected afterwards. You needtry { await boom(); }for thecatchto see anything.
Error Handling with try, catch and finally
When you await a promise that rejects, it throws — so you handle it with the same try/catch you would use for any other error. That is the second big readability win, because the success path and the failure path finally sit next to each other in one function instead of in separate callbacks.
The shape is simple: try around the awaits, catch (err) for the failure, and finally for cleanup that must happen whichever way it went. Those are exactly the same three blocks Lesson 26 covers for synchronous errors, and they behave identically here.
The trap is scope. A try/catch only catches what is physically inside it, so an await written above the try block is completely unprotected — and an await inside a callback that was created in the try is not covered either, because that callback runs later, when the block has long since finished. Keep the awaits you care about inside the block.
One judgement point: do not catch an error you cannot do anything about. An empty catch, or one that only prints to the console, turns a failure into silence and lets the rest of the function carry on with missing data — which usually produces a second, more confusing error somewhere else. Either handle it properly (show a message, use a fallback, retry) or let it propagate to a caller who can.
function fail(ms, message) {
return new Promise((resolve, reject) => {
setTimeout(() => reject(new Error(message)), ms);
});
}
async function loadDashboard() {
const button = document.querySelector('#reload');
const status = document.querySelector('#status');
button.disabled = true;
try {
const data = await fail(100, 'Network unavailable');
console.log('loaded', data);
} catch (err) {
console.error('Could not load:', err.message);
status.textContent = 'Could not load. Please try again.';
} finally {
button.disabled = false; // runs on the success path AND the failure path
}
}
// Unprotected: this await is outside the try block
async function wrong() {
const data = await fail(50, 'boom'); // rejects here; nothing catches it
try {
console.log(data);
} catch (e) {
console.log('never reached');
}
}
wrong().catch(e => console.log('the caller had to catch it:', e.message)); - The
finallyblock above is what keeps the reload button usable after a failure. Re-enabling it only insidetryis the exact bug that leaves a page apparently frozen after one bad request.
The Four Mistakes Everyone Makes
Forgetting await. The line runs, the function carries straight on, and the variable holds a promise instead of data. The symptoms are undefined properties, the text [object Promise] appearing in output, and code that seems to execute in the wrong order. Nothing throws and nothing warns. When asynchronous code is behaving strangely, this is the first thing to check.
Awaiting inside a loop when the items are independent. A for...of loop with an await in its body processes items strictly one at a time. That is exactly right when each step needs the previous step's result, and needlessly slow when it does not — ten requests of 200 milliseconds each take two seconds instead of a fifth of one. For the independent case, use Promise.all with map.
Using forEach with an async callback. forEach throws away the promise its callback returns, so it starts all of them and returns immediately. Every line after the loop then runs on data that has not arrived, and there is no warning of any kind. for...of waits; forEach does not. This is the same limitation Lesson 9 flagged, and this is where it costs you.
Losing errors. Calling an async function without await and without .catch means any rejection becomes an unhandled rejection that nobody sees. The most common place this happens is an event listener: button.addEventListener('click', async () => { ... }) hands the browser a promise that nothing will ever inspect. Put a try/catch inside the handler.
function delay(ms, value) {
return new Promise(resolve => setTimeout(() => resolve(value), ms));
}
async function demo() {
// 1. Forgetting await
const wrong = delay(50, 'data');
console.log(wrong); // Promise { <pending> }
const right = await delay(50, 'data');
console.log(right); // 'data'
const ids = [1, 2, 3];
// 2. Sequential when it never needed to be: about 300ms
for (const id of ids) {
await delay(100, id);
}
// Parallel: about 100ms
await Promise.all(ids.map(id => delay(100, id)));
// 3. forEach does not wait at all
ids.forEach(async (id) => {
await delay(100, id);
console.log('this prints AFTER the line below it');
});
console.log('forEach returned immediately');
}
demo();
// 4. An async event handler needs its own try/catch
document.querySelector('#save').addEventListener('click', async () => {
try {
await delay(50, 'saved');
console.log('saved');
} catch (err) {
console.error('Save failed:', err.message);
}
}); - A linter rule that flags a promise which is never awaited or handled catches mistakes 1 and 4 automatically. If your project has ESLint, turning that rule on is one of the highest-value five minutes you can spend.
Sequential or Parallel: Deciding Deliberately
The decision is always the same question: does this step need the previous step's result? If it does, await them in order and accept the total time. If it does not, start them together. Answering that question consciously, once per function, is most of what makes asynchronous code fast.
Starting work together has two forms. Promise.all with map is the usual one and reads clearly. The other is to call the functions first, collecting the promises into variables, and await them afterwards — because the work begins at the call, not at the await, so by the time you await the second one it has already been running alongside the first.
Be careful with Promise.all and failure: one rejection rejects the whole thing, and the other requests keep running with their results discarded. When partial success is acceptable — a dashboard where one panel failing should not blank the page — Promise.allSettled is the right choice, and you then check each result's status before using it.
The demo below runs the same three fake requests both ways and times them, so the difference is something you can see rather than something you have to take on trust. It uses performance.now() for the timing, for the reason Lesson 13 gave: it is a monotonic timer and cannot jump if the system clock is adjusted.
function fetchThing(label, ms) {
return new Promise(resolve => setTimeout(() => resolve(label), ms));
}
// Dependent: each step genuinely needs the one before it
async function sequential() {
const student = await fetchThing('student', 200);
const marks = await fetchThing(student + '-marks', 200);
return marks; // about 400ms
}
// Independent: start them together
async function parallel() {
const [students, subjects, terms] = await Promise.all([
fetchThing('students', 200),
fetchThing('subjects', 200),
fetchThing('terms', 200)
]);
return [students, subjects, terms]; // about 200ms
}
// The other parallel form: call first, await afterwards
async function alsoParallel() {
const a = fetchThing('a', 200); // starts now
const b = fetchThing('b', 200); // also starts now
return [await a, await b]; // about 200ms in total
}
sequential().then(r => console.log('sequential:', r));
parallel().then(r => console.log('parallel:', r));
alsoParallel().then(r => console.log('also parallel:', r)); HTML
<div class="aw-demo">
<h3>Three requests, two ways</h3>
<button id="seq">One after another</button>
<button id="par">All together</button>
<pre id="out">Press a button.</pre>
</div> CSS
.aw-demo { padding: 20px; background: #f0f0f0; border-radius: 8px; font-family: system-ui, sans-serif; }
button { padding: 10px 16px; background: #d1039e; color: white; border: none; border-radius: 5px; cursor: pointer; margin: 4px 6px 4px 0; }
button:disabled { background: #999; cursor: default; }
pre { background: white; padding: 14px; border-radius: 5px; border-left: 4px solid #d1039e; min-height: 100px; margin-top: 12px; white-space: pre-wrap; font-size: 0.9rem; } JavaScript
const out = document.getElementById('out');
const seqBtn = document.getElementById('seq');
const parBtn = document.getElementById('par');
// A fake request: resolves with a label after a delay
function fakeRequest(label, ms) {
return new Promise(function (resolve) {
setTimeout(function () { resolve(label); }, ms);
});
}
function log(line) {
out.textContent = out.textContent + '\n' + line;
}
async function runSequential() {
out.textContent = 'One after another...';
const t0 = performance.now();
log(await fakeRequest('students loaded', 400));
log(await fakeRequest('marks loaded', 400));
log(await fakeRequest('attendance loaded', 400));
log('Total: ' + Math.round(performance.now() - t0) + ' ms');
}
async function runParallel() {
out.textContent = 'All together...';
const t0 = performance.now();
const results = await Promise.all([
fakeRequest('students loaded', 400),
fakeRequest('marks loaded', 400),
fakeRequest('attendance loaded', 400)
]);
results.forEach(log);
log('Total: ' + Math.round(performance.now() - t0) + ' ms');
}
async function run(fn) {
seqBtn.disabled = true;
parBtn.disabled = true;
try {
await fn();
} catch (err) {
log('Error: ' + err.message);
} finally {
seqBtn.disabled = false; // re-enabled on BOTH paths
parBtn.disabled = false;
}
}
seqBtn.addEventListener('click', function () { run(runSequential); });
parBtn.addEventListener('click', function () { run(runParallel); }); - Run both buttons and compare the totals: roughly 1200 milliseconds against roughly 400. Nothing about the three requests changed — only whether they were started together. Notice too that the buttons are re-enabled in a
finally, so a failure could never leave the demo stuck.
Top-Level await, and Where async Fits
Inside an ES module you can use await at the top level, with no wrapping function at all. That is convenient for loading configuration or for a dynamic import. It is not available in a classic script, and it delays the module's own evaluation — anything importing that module waits for it — so use it deliberately rather than by habit.
Outside a module, the traditional workaround is an immediately-invoked async function: (async () => { ... })();. You will still meet it constantly in existing code, and it is worth recognising for what it is — a way to open an async scope where the language did not otherwise give you one.
Should you use async/await everywhere in place of .then? Mostly yes. A chain still reads well in a few narrow places — a single transformation applied to a promise a function is about to return, for instance. What you should avoid is mixing the two styles inside one function, which forces the reader to track two mental models at once. Pick one style per function and keep to it.
Finally, be clear about what async does not do. It does not make anything faster. It does not create a thread. It does not turn asynchronous work into synchronous work. It changes how the waiting is written, and every rule from the previous lesson about promises, microtasks and the event loop applies exactly as before.
// In a module (type="module"), top-level await is allowed:
// const config = await fetch('/config.json').then(r => r.json());
// In a classic script, open an async scope yourself
(async () => {
const value = await Promise.resolve('ready');
console.log(value); // 'ready'
})();
// Mixing styles in one function - it works, and it reads badly
async function mixed() {
const a = await Promise.resolve(1);
return Promise.resolve(2).then(b => a + b);
}
// One style, straight down the page
async function clear() {
const a = await Promise.resolve(1);
const b = await Promise.resolve(2);
return a + b;
}
clear().then(total => console.log(total)); // 3 awaitonly inside anasyncfunction, or at the top level of a module- An
asyncfunction always returns a promise, whatever you return from it try/catch/finallyaround your awaits is how you handle failure- Forgetting
awaitsilently gives you a promise where you expected data for...ofwaits for eachawait;forEachdoes not wait at all- Independent work:
await Promise.all(items.map(...)) - An
asyncevent handler needs its owntry/catch asyncchanges how waiting is written, not how fast anything runs
- If you can explain why
forEachwith anasynccallback does not wait, you understand both this lesson and the previous one. The answer is short: the callback returns a promise, andforEachthrows it away.
