Lesson 0.7 · JavaScript in general
Asynchronous JavaScript
setTimeout and the event loop, Promise, async/await, errors, Promise.all and fetch.
Loading lesson…
Lesson 0.7 · JavaScript in general
setTimeout and the event loop, Promise, async/await, errors, Promise.all and fetch.
Loading lesson…
Picture a restaurant with a single waiter. If after every order they stood by the kitchen waiting for the cook to finish, the other guests wouldn't even get to order. So they do it the smart way: they take the order to the kitchen, leave the guest a ticket ("your food is coming") and go to serve the next table. When the cook rings the bell, they bring the food.
JavaScript in the browser is that waiter – it has only one thread. If it waited for the server's answer (which can easily take a second), the whole page would freeze: you couldn't click, type or scroll. So slow things aren't waited for, they're ordered – and the ticket for a future result is called a Promise.
console.log('A')setTimeout(() => console.log('B – after 1 second'), 1000)console.log('C')// Prints: A, C, … and a second later B// setTimeout doesn't wait! It just "orders" a function call and the code carries on.const id = setTimeout(() => alert('Boom'), 5000)clearTimeout(id) // cancels the planned callconst tick = setInterval(() => console.log('tick'), 1000) // every secondclearInterval(tick) // stops it
The waiter has rules: first they finish what they're doing right now (synchronous code). Then they check the urgent tickets (microtasks – fulfilled Promises). Only then do they take the next ordinary task from the queue (setTimeout, a click, a server response).
console.log('1')setTimeout(() => console.log('4'), 0) // even with zero, after everything elsePromise.resolve().then(() => console.log('3')) // a microtask – before the timeoutconsole.log('2')// 1, 2, 3, 4
What you see: Code with console.log, setTimeout and a Promise. The button runs it and shows the order the output appeared in.
Try it:
The takeaway: setTimeout(fn, 0) doesn't mean "right now" but "when there's time". Synchronous code always finishes completely before anything asynchronous gets its turn.
Loading the interactive part…
A Promise is an object that stands in for a value that isn't available yet. It can be in one of three states:
| State | Meaning | The restaurant ticket |
|---|---|---|
pending | we're waiting | the food is cooking |
fulfilled | fulfilled – we have the value | the food is on the table |
rejected | rejected – an error happened | they ran out of ingredients |
const promise = fetchUser(1) // returns a Promise right away, the data isn't there yetconsole.log(promise) // Promise { <pending> }// .then – "when it's ready, do this"promise.then((user) => console.log(user.name))// .catch – "if it goes wrong", .finally – "either way"fetchUser(99).then((user) => console.log(user.name)).catch((error) => console.error('It failed:', error.message)).finally(() => console.log('Done (success or error)'))
fetchUser(1).then((user) => fetchPosts(user.id)) // returns another Promise….then((posts) => posts.items.length) // …and the next then gets its result.then((count) => console.log(count)).catch((error) => console.error(error)) // one catch for errors from the whole chain
Usually you just receive Promises (from fetch, from libraries). But sometimes it's handy to make your own – typically for waiting:
function sleep(ms) {return new Promise((resolve) => {setTimeout(resolve, ms) // after ms it calls resolve → the Promise is fulfilled})}sleep(1000).then(() => console.log('A second has passed'))// A Promise that can failfunction rollDice() {return new Promise((resolve, reject) => {const n = Math.ceil(Math.random() * 6)if (n === 1) reject(new Error('You rolled a one'))else resolve(n)})}
.then chains work, but they're hard to read. async/await does the same thing and reads like ordinary code, top to bottom:
// With thenfunction showUserPosts(id) {return fetchUser(id).then((user) => fetchPosts(user.id)).then((posts) => console.log(posts.items))}// With async/await – the sameasync function showUserPosts(id) {const user = await fetchUser(id) // wait for the userconst posts = await fetchPosts(user.id) // then for the postsconsole.log(posts.items)}
await "unwraps" a Promise: it waits until it's fulfilled and returns its value. Only this function waits – the rest of the page keeps running.await can only be used inside a function marked async (or at the top level of a module).async function always returns a Promise. Even if you write return 5, the caller gets a Promise with a five.async function getNumber() {return 5}getNumber() // Promise { 5 } – not 5!const n = await getNumber() // 5getNumber().then((n) => …) // or like this// An async arrow functionconst loadUser = async (id) => {const user = await fetchUser(id)return user.name}
async function loadProfile(id) {try {const user = await fetchUser(id) // if this fails…return user} catch (error) {console.error(error.message) // …it jumps herereturn null} finally {console.log('Loading finished') // always runs (success or error)}}
When you need several independent things, there's no point waiting for each one separately. A waiter doesn't take orders to the kitchen one at a time, waiting for each to be cooked, either.
// ❌ One by one: 4 × ~1 s = ~4 sconst users = []for (const id of [1, 2, 3, 4]) {users.push(await fetchUser(id))}// ✅ All at once: ~1 s (as long as the slowest one takes)const users = await Promise.all([1, 2, 3, 4].map((id) => fetchUser(id)))// ↑ map starts all 4 loads right away, all waits for all of themconst [user, posts] = await Promise.all([fetchUser(1), fetchPosts(1)]) // different things at once
What you see: Two buttons that load four users from the fake API (each answers in 0.3–1.5 s). The bars show when each load started and finished.
Try it:
The takeaway: Start independent loads all at once with Promise.all. One by one only when the next step needs the result of the previous one.
Loading the interactive part…
| Method | Fulfilled when… | Rejected when… |
|---|---|---|
Promise.all | all of them are fulfilled (results in order) | any of them fails (right away) |
Promise.allSettled | all of them finish – successfully or not | never |
Promise.race | the first one finishes successfully | the first one finishes with an error |
Promise.any | any of them is fulfilled | all of them fail |
// allSettled – when you don't want one error to bring everything downconst results = await Promise.allSettled([fetchUser(1), fetchUser(99)])// [{ status: 'fulfilled', value: {…} }, { status: 'rejected', reason: Error }]const found = results.filter((r) => r.status === 'fulfilled').map((r) => r.value)// race – a time limitconst timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Timed out')), 3000))const user = await Promise.race([fetchUser(1), timeout])
In the course we use a fake API (src/course/fakeApi.en.ts) so everything works offline. In a real app you talk to the server with the built-in fetch function:
async function loadUsers() {const response = await fetch('https://api.example.com/users')if (!response.ok) { // ⚠️ fetch does NOT fail on 404 or 500!throw new Error(`The server returned ${response.status}`)}const users = await response.json() // the response body → a JS object (also a Promise)return users}// Sending dataawait fetch('/api/posts', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ title: 'Hello' }), // object → text})
const controller = new AbortController()fetch('/api/search?q=react', { signal: controller.signal }).then((r) => r.json()).catch((e) => {if (e.name === 'AbortError') return // we cancelled it ourselves – not an errorthrow e})controller.abort() // cancels the request in progress
It's handy for search, for example: when the user keeps typing, nobody cares about the old request any more. In React, this is how requests are cancelled in an effect's cleanup (lesson 3).
// ❌ A forgotten await – you have a Promise, not the dataconst user = fetchUser(1)user.name // undefined – a Promise has no name// ❌ await in forEach doesn't wait – forEach knows nothing about Promisesids.forEach(async (id) => {await save(id)})console.log('Done') // prints RIGHT AWAY, the saving is still running// ✅ for…of (one by one) or Promise.all (all at once)for (const id of ids) await save(id)await Promise.all(ids.map((id) => save(id)))// ❌ map with async returns an array of Promisesconst names = ids.map(async (id) => (await fetchUser(id)).name) // [Promise, Promise…]// ✅ wrap it in Promise.allconst names = await Promise.all(ids.map(async (id) => (await fetchUser(id)).name))// ❌ An error without catchasync function load() {const data = await fetchUser(99) // throws an error and nobody catches it}
Two short async functions: one waits a moment, the other loads a user from the fake API.
CHALLENGE: Wait and answer 1. getAnswer() is an async function: first wait 100 ms (await sleep(100)), then return 42. 2. loadCity(1) should load the user (await fetchUser(id)) and return their city – 'London'.
Loading the interactive part…
Six functions over the fake API: waiting, loading one and several users at once, handling an error and a time limit. The tests take a moment because the API answers with a delay.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
try/catch; finally always runs.Promise.all. await in forEach doesn't work.fetch doesn't fail on 404/500 – check response.ok. You can cancel with AbortController.Checks, Check, fetchUser, sleep)Checks, Check – from the file src/course/checks.tsx. The course helper for function-style challenges: runs the tests and shows ✅/❌. You don't need to know how it works inside – it just tells you which steps are done./** One test: a description and a function returning true (passed) or false. It may be async. */export type Check = [name: string, test: () => boolean | Promise<boolean>]/*** Runs the tests of a challenge and shows ✅ or ❌ for each.* An error thrown in a test (e.g. when trying to change a frozen object) is shown under the test.*/export function Checks({ tests }: { tests: Check[] }) {const t = useT()const [results, setResults] = useState<Result[]>(() => tests.map(() => ({ status: 'pending' })))useEffect(() => {let ignore = falsetests.forEach(([, test], i) => {Promise.resolve().then(test).then((ok): Result => (ok ? { status: 'ok' } : { status: 'fail' }),(e): Result => ({ status: 'fail', error: e instanceof Error ? e.message : String(e) }),).then((result) => {if (ignore) returnsetResults((prev) => prev.map((r, idx) => (idx === i ? result : r)))})})return () => {ignore = true}}, [tests])const passed = results.filter((r) => r.status === 'ok').lengthreturn (<div className="stack"><strong className={passed === tests.length ? 'ok' : undefined}>{t.checksPassed(passed, tests.length)}</strong><ul className="list">{tests.map(([name], i) => {const r = results[i] ?? { status: 'pending' }return (<li key={name}>{r.status === 'pending' ? '⏳' : r.status === 'ok' ? '✅' : '❌'} {name}{r.status === 'fail' && r.error && (<div className="bad muted">{t.checksError} {r.error}</div>)}</li>)})}</ul></div>)}
fetchUser, sleep – from the file src/course/fakeApi.en.ts. The course fake API: data and functions pretending to be a server (with a delay, sometimes even with an error). In a real app you would call fetch() here.export interface User {id: numbername: stringemail: stringcity: string}export async function fetchUser(id: number): Promise<User> {await sleep(randomDelay())const user = users.find((u) => u.id === id)if (!user) throw new Error(`User ${id} doesn't exist`)return user}export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))