Webbo3 Level 2 · JavaScript Development · Day 7 of 20
Asynchronous JavaScript
What your code does while it is waiting for something slow, and why the answer changes the way you write it.
By the end of today you can
- Explain why JavaScript does not simply stop and wait
- Read the order things actually happen in, rather than the order they are written
- Write and consume a Promise
- Use
asyncandawaitto make asynchronous code read like ordinary code - Handle a failure with
try/catchinstead of letting the page die quietly
Everything you have written so far finished instantly. From tomorrow you will ask a server in another country for data, and that takes somewhere between a tenth of a second and forever. JavaScript has one thread: if it stopped and waited, the whole page would freeze, no button would respond and no animation would move. So it does not wait. Understanding what it does instead is today.
1. Order is not the order you wrote
console.log('1. first');
setTimeout(() => {
console.log('3. inside the timer, zero milliseconds');
}, 0);
console.log('2. last line of the file');Zero milliseconds, and it still runs last. setTimeout does not mean "run this in 0ms", it means "hand this to the browser and carry on". The browser puts the function in a queue, JavaScript finishes everything it was already doing, and only when the call stack is completely empty does it pick the next thing off the queue.
The mental model worth keeping
JavaScript runs one thing at a time, to completion, and never interrupts itself. Anything slow is handed out to the browser, which brings the result back later by putting a function in a queue. That is the whole of the event loop, and it explains every surprising order you will ever see.
2. The bug this creates
function getUser() {
let user;
setTimeout(() => { user = { name: 'Chidinma' }; }, 50);
return user; // runs immediately, long before 50ms
}
console.log(getUser());undefined, every time. The return runs immediately; the assignment happens 50 milliseconds later, into a variable nobody is looking at any more. You cannot return a value that has not arrived yet. Every asynchronous tool in the language exists to answer this one problem.
3. Callbacks, and why they stopped being enough
function getUser(callback) {
setTimeout(() => callback({ name: 'Chidinma' }), 50);
}
getUser((user) => {
console.log('got', user.name);
});That works. The trouble starts when one thing depends on the next, and then on the next again:
getUser(1, (user) => {
getOrders(user.id, (orders) => {
getItems(orders[0].id, (items) => {
getPrice(items[0].id, (price) => {
console.log(price); // four levels deep, and no error handling yet
});
});
});
});That shape has a name, callback hell, and the reason it is more than ugly is error handling: every one of those four steps can fail, and each needs its own check. Promises were added to the language in 2015 specifically to flatten this.
4. Promises
A Promise is an object representing a value that has not arrived yet. It is in one of three states: pending, then either fulfilled with a value or rejected with a reason. It never goes back.
function getUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id <= 0) {
reject(new Error('No such user'));
return;
}
resolve({ id, name: 'Chidinma' });
}, 50);
});
}
getUser(1)
.then((user) => console.log('resolved:', user.name))
.catch((error) => console.log('rejected:', error.message));
getUser(-1)
.then((user) => console.log('resolved:', user.name))
.catch((error) => console.log('rejected:', error.message));.then() runs on success, .catch() runs on failure, and because .then() returns a Promise you can chain them into a flat line rather than a pyramid. One .catch() at the end catches a failure from any step above it, which is the real win.
5. async and await
Promises flattened the pyramid. async/await removes it entirely and lets asynchronous code read top to bottom like everything else you have written.
function delay(ms, value) {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
async function loadDashboard() {
console.log('loading...');
const user = await delay(40, { name: 'Chidinma' });
const orders = await delay(40, ['order-1', 'order-2']);
return `${user.name} has ${orders.length} orders`;
}
loadDashboard().then((summary) => console.log(summary));awaitmay only be used inside a function markedasync.awaitpauses that function until the Promise settles. It does not freeze the page: everything else carries on.- An
asyncfunction always returns a Promise, even when you return a plain value. That is why the call above still needs.then().
The mistake that prints a Promise instead of your value
Calling an async function without await or .then() hands you the Promise object rather than the value inside it. Node prints it as Promise { 4500 } and a browser as Promise {<fulfilled>: 4500}; while it is still running, both say pending. Any time the word Promise appears in your output where a value should be, you forgot to await.
async function getTotal() { return 4500; }
console.log(getTotal()); // the Promise, not the number
getTotal().then((n) => console.log('awaited:', n));6. Two awaits in a row is often a mistake
await is sequential. If the second request does not need the first one's answer, waiting for them one after the other doubles the time for no reason.
function delay(ms, value) {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
async function sequential() {
const started = Date.now();
const a = await delay(60, 'courses');
const b = await delay(60, 'students');
return `sequential: ${a} + ${b} in about ${Math.round((Date.now() - started) / 10) * 10}ms`;
}
async function parallel() {
const started = Date.now();
const [a, b] = await Promise.all([delay(60, 'courses'), delay(60, 'students')]);
return `parallel: ${a} + ${b} in about ${Math.round((Date.now() - started) / 10) * 10}ms`;
}
sequential().then(console.log).then(() => parallel().then(console.log));Same two pieces of work, roughly half the wait. Use Promise.all whenever the requests are independent. Use sequential await only when the second genuinely needs something from the first.
7. Handling failure
function mightFail(shouldFail) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) reject(new Error('Network unreachable'));
else resolve('data arrived');
}, 20);
});
}
async function load(shouldFail) {
try {
const result = await mightFail(shouldFail);
console.log('ok:', result);
} catch (error) {
console.log('handled:', error.message);
} finally {
console.log('finally always runs, spinner off');
}
}
load(false).then(() => load(true));An unhandled rejection is a silent page
Forget the try/catch and a failed request throws into nothing. The user sees a spinner that never stops, no error, no explanation, and they conclude your app is broken rather than their connection. finally is where the spinner gets turned off, precisely because it runs whether things worked or not.
8. The three states a real interface must have
This is the part beginners skip and users notice. Anything asynchronous has three possible states on screen, and you have to design all three.
| State | What the user sees | What you must do |
|---|---|---|
| Loading | A spinner, a skeleton, or "Loading..." | Show it before the request, hide it in finally |
| Error | A plain message and a way to retry | Catch, and say what happened in words a human understands |
| Empty | "No results for that search" | Success with zero items is not an error, and it is not blank either |
// the shape every fetch in this course will follow
async function load() {
showLoading();
try {
const items = await getItems();
if (items.length === 0) showEmpty();
else showItems(items);
} catch (error) {
showError(error.message);
} finally {
hideLoading();
}
}Do this now
- Write
delay(ms)returning a Promise, then an async function that logs "one", waits a second, logs "two", waits again, logs "three". Watch the timing. - Rewrite it with
Promise.allso all three finish together, and time both. - Write a function that rejects half the time at random, call it ten times, and count successes and failures with
try/catch. - Predict the output of the ordering example at the top before running it, then add a
Promise.resolve().then()into the mix and predict again.
Checkpoint
Q1. Why does `setTimeout(fn, 0)` still run after the last line of the file?
Because it is queued rather than run. JavaScript finishes all the work it already has before taking anything off the queue, so "0 milliseconds" means "as soon as I am completely free", not "now".
Q2. Your console prints `Promise { }`. What did you forget?
To await it, or to attach .then(). You printed the Promise object itself rather than the value it will eventually hold.
Q3. When should you use `Promise.all` instead of two awaits?
Whenever the second request does not need the first one's result. Two independent awaits take the sum of both waits; Promise.all takes the longer of the two.
Q4. Why does the spinner belong in `finally` rather than after the `try`?
Because finally runs whether the request succeeded or threw. Put it after the try block only and a failed request leaves the spinner turning forever.
Tick before you move on
- ☐ I predicted the ordering example correctly before running it
- ☐ I wrote a Promise by hand with both resolve and reject
- ☐ I timed sequential against Promise.all and saw the difference
- ☐ Every async function I wrote today has a catch and a finally
Quick recap
One thread: JavaScript never waits, it hands slow work out and carries on · You cannot return a value that has not arrived yet · .then/.catch flattened callbacks; async/await removed the nesting · Promise.all for independent work, sequential await only when dependent · Loading, error and empty are three states you must design, not one
Tomorrow, Day 8: the thing you have been waiting for. Real HTTP requests to real APIs, and what all those status codes mean.