Courses Learn Blog Contact
Get Started →
Lesson 7

Day 7: Asynchronous JavaScript

The event loop, callbacks, Promises and async/await, with real timings showing why Promise.all halves the wait, and the three states every asynchronous interface needs.

Webbo3 Level 2: JavaScript Development 13 min read Free

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 use a Promise, with both success and failure
  • Use async and await so waiting code reads like ordinary code
  • Run independent work together with Promise.all
  • Handle failure with try, catch and finally, and design loading, error and empty states

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: it can only do one thing at a time. If it stopped and waited, the whole page would freeze. No button would respond and nothing would move. So it does not wait. Understanding what it does instead is today.

1. Synchronous and Asynchronous

Definition

Synchronous code runs line by line, each line finishing before the next starts. Asynchronous code starts something slow, hands it off, and carries on; the result comes back later and is handled then.

Real-Life Example

At a restaurant, the waiter takes your order to the kitchen, then goes to serve other tables. They do not stand at the kitchen door until your food is ready. When it is ready, the kitchen rings a bell, and the waiter brings it when they are next free.

JavaScript is the waiter. The browser (or Node) is the kitchen. The bell is a queue of finished jobs waiting to be picked up.

How It Works: The Event Loop

  1. JavaScript runs your code from top to bottom, one thing at a time.
  2. Anything slow (a timer, a network request) is handed to the browser.
  3. When the slow thing finishes, the browser puts a function in a queue.
  4. Only when JavaScript has completely finished what it was doing does it take the next function off the queue.

Example 1: A Timer

setTimeout(fn, ms) asks for fn to be run after ms milliseconds. Predict the order of the three lines before you read the output.

What this code doesWe want to see that a timer of zero milliseconds still runs after the rest of the file, because it waits in the queue.
app.js
1
2
3
4
5
6
7
8
9
10
// Runs straight away
console.log('1. first');

// Hand this function to the timer, with a wait of zero milliseconds
setTimeout(() => {
  console.log('3. inside the timer');
}, 0);

// Runs straight away too
console.log('2. last line of the file');
OUTPUT   TERMINAL
➜ webbo3 node app.js
1. first
2. last line of the file
3. inside the timer

Zero milliseconds, and it still runs last. It does not mean "run this now", it means "put this in the queue", and the queue is only looked at once everything else is finished.

Example 2: Two Timers

What this code doesWe want to see that timers finish in order of their wait time, not in the order they were written.
app.js
1
2
3
4
5
6
7
// The longer wait is written first
setTimeout(() => console.log('slow: 200ms'), 200);

// The shorter wait is written second
setTimeout(() => console.log('fast: 50ms'), 50);

console.log('both timers started');
OUTPUT   TERMINAL
➜ webbo3 node app.js
both timers started
fast: 50ms
slow: 200ms
Picture This

Picture the waiter and the kitchen bell. The waiter never stands still waiting for one dish. Whatever the kitchen finishes first is served first, and only when the waiter's hands are free.

2. The Bug This Creates

Definition

Because slow work finishes later, a function cannot return its result straight away. By the time the result arrives, the function has already returned.

Real-Life Example

You ask a friend to buy bread and immediately look in the bread bin. It is empty. The bread is coming, but you looked before it arrived.

What this code doesWe want to see what a function gives back when it tries to return a value that has not arrived yet.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function getUser() {
  // Start with nothing
  let user;

  // The user "arrives" 50 milliseconds later
  setTimeout(() => {
    user = { name: 'Chidinma' };
  }, 50);

  // This runs immediately, long before 50ms
  return user;
}

console.log(getUser());
OUTPUT   TERMINAL
➜ webbo3 node app.js
undefined
Why This Happens

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 tool in the rest of this lesson exists to solve this one problem.

Picture This

Picture looking in the bread bin the moment your friend leaves for the shop. The bread is real, it is just not here yet.

3. Callbacks, and Why They Stopped Being Enough

Definition

The first solution was a callback (Day 3): instead of returning the value, the slow function accepts a function and calls it when the value arrives.

Real-Life Example

Instead of checking the bread bin, you tell your friend: "When you get back, call me." You carry on with your day, and your phone rings when the bread is here.

What this code doesWe want the slow function to call us back with the user once it has arrived, instead of returning too early.
app.js
1
2
3
4
5
6
7
8
9
10
11
// Accept a function to call when the user arrives
function getUser(callback) {
  setTimeout(() => {
    callback({ name: 'Chidinma' });
  }, 50);
}

// Hand over what to do once the user is here
getUser((user) => {
  console.log('got', user.name);
});
OUTPUT   TERMINAL
➜ webbo3 node app.js
got Chidinma

Common Mistake: Callback Hell

That works for one step. The trouble starts when each step needs the one before it:

What this code doesWe want to see the shape code takes when several slow steps depend on each other using callbacks.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Three slow steps, each needing the result of the last
function getUser(id, callback) {
  setTimeout(() => callback({ id, name: 'Chidinma' }), 30);
}
function getOrders(userId, callback) {
  setTimeout(() => callback([{ id: 7, userId }]), 30);
}
function getItems(orderId, callback) {
  setTimeout(() => callback(['Headphones', 'Charger']), 30);
}

// Each step nests inside the one before it
getUser(1, (user) => {
  getOrders(user.id, (orders) => {
    getItems(orders[0].id, (items) => {
      console.log(`${user.name}'s first order: ${items.join(', ')}`);
    });
  });
});
OUTPUT   TERMINAL
➜ webbo3 node app.js
Chidinma's first order: Headphones, Charger
Why This Matters

That staircase shape is called callback hell. It is more than ugly: every step can fail, and each level would need its own error check. Promises were added to the language specifically to flatten it.

Picture This

Picture a set of Russian dolls: every step sits inside the one before, and to find the result you have to open each doll in turn.

4. Promises

Definition

A Promise is an object that stands for a value that has not arrived yet. It is always in one of three states, and it only ever changes once.

Real-Life Example

The buzzer a fast-food restaurant hands you after you order. Right now it is quiet (pending). Later it either buzzes because your food is ready (fulfilled), or the cashier tells you they have run out (rejected). It never goes back to quiet.

Types: The Three States

StateMeaning
PendingStill waiting
FulfilledIt worked, and here is the value
RejectedIt failed, and here is the reason

Creating a Promise

new Promise((resolve, reject) => { ... }) makes a Promise. Inside, call resolve(value) when it works and reject(reason) when it fails. new Error(message) makes an error object with a .message.

Using a Promise: then and catch

.then(fn) runs when the Promise is fulfilled; .catch(fn) runs when it is rejected.

What this code doesWe want a lookup that returns a Promise, and to see what happens when it succeeds and when it fails.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// A slow lookup that can succeed or fail
function getUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // A negative id is not valid
      if (id < 0) {
        reject(new Error('No user with that id'));
      } else {
        resolve({ id, name: 'Chidinma' });
      }
    }, 30);
  });
}

// A valid id: then runs
getUser(1)
  .then((user) => console.log('fulfilled:', user.name))
  .catch((error) => console.log('rejected:', error.message));

// An invalid id: catch runs
getUser(-1)
  .then((user) => console.log('fulfilled:', user.name))
  .catch((error) => console.log('rejected:', error.message));
OUTPUT   TERMINAL
➜ webbo3 node app.js
fulfilled: Chidinma
rejected: No user with that id

Overall Example: Flattening the Staircase

What this code doesWe want the three-step callback staircase rewritten as a flat chain of Promises, with one catch for every step.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
// The same three steps, now returning Promises
const wait = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
const getUser = (id) => wait(30, { id, name: 'Chidinma' });
const getOrders = (userId) => wait(30, [{ id: 7, userId }]);
const getItems = (orderId) => wait(30, ['Headphones', 'Charger']);

// Each then returns the next Promise, so the chain stays flat
getUser(1)
  .then((user) => getOrders(user.id))
  .then((orders) => getItems(orders[0].id))
  .then((items) => console.log(`First order: ${items.join(', ')}`))
  .catch((error) => console.log('Something failed:', error.message));
OUTPUT   TERMINAL
➜ webbo3 node app.js
First order: Headphones, Charger

One catch at the end handles a failure from any step above it. That, more than the flatter shape, is the real improvement.

Picture This

Picture the restaurant buzzer. You hold a promise of food. then is "when it buzzes, eat"; catch is "if they run out, order something else".

5. async and await

Definition

async marks a function that contains waiting. Inside it, await pauses that function only until a Promise settles, then gives you its value directly, so the code reads top to bottom like everything else you have written.

Real-Life Example

Following a recipe: "Boil the rice. When it is done, add the stew." You wait for the rice before the next step, but while it boils the rest of the kitchen keeps working.

How It Works

  1. await can only be used inside a function marked async.
  2. await pauses the function, not the page. Everything else carries on.
  3. An async function always returns a Promise, even when you return a plain value.
What this code doesWe want to load two things one after the other and combine them, written as plainly as ordinary code.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// A reusable wait: resolves with a value after ms milliseconds
function delay(ms, value) {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}

// Reads top to bottom, waiting at each await
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`;
}

// The async function returns a Promise, so use then here
loadDashboard().then((summary) => console.log(summary));
OUTPUT   TERMINAL
➜ webbo3 node app.js
loading...
Chidinma has 2 orders

Common Mistake: Printing a Promise Instead of the Value

What this code doesWe want to see what you get when you call an async function without waiting for it.
app.js
1
2
3
4
5
6
7
8
9
10
// An async function always returns a Promise
async function getTotal() {
  return 4500;
}

// Forgot to await: this prints the Promise itself
console.log(getTotal());

// Waited properly: this prints the value inside it
getTotal().then((total) => console.log('awaited:', total));
OUTPUT   TERMINAL
➜ webbo3 node app.js
Promise { 4500 }
awaited: 4500
How to Spot It

Node prints the Promise as Promise { 4500 } and a browser as Promise {<fulfilled>: 4500}; while it is still running, both say pending. Any time the word Promise appears where a value should be, you forgot to await.

Try This

Write an async function that waits half a second, then logs a message, then waits again and logs a second one.

What this code doesPractise: use await to pause between steps.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
// A reusable wait
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function countdown() {
  console.log('Get ready');
  await delay(500);
  console.log('Set');
  await delay(500);
  console.log('Go!');
}

countdown();
OUTPUT   TERMINAL
➜ webbo3 node app.js
Get ready
Set
Go!
Picture This

Picture the recipe card. await is the line that says "wait until the rice is done". The cook pauses that recipe, not the whole kitchen.

6. Two awaits in a Row Is Often a Mistake

Definition

await is sequential: the second wait only starts when the first ends. When two pieces of work do not depend on each other, Promise.all([...]) starts them together and waits for both.

Real-Life Example

Making tea and toast. You can put the kettle on, wait for it to boil, and only then put the bread in the toaster. Or you can start both at once, and breakfast is ready in half the time.

Date.now() gives the current time in milliseconds, so subtracting two readings measures how long something took.

What this code doesWe want to time the same two pieces of work done one after the other, and done together with Promise.all.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const delay = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));

// Round to the nearest 60ms so the timing is easy to read
const roughly = (ms) => Math.round(ms / 60) * 60;

// One after the other: about 60 + 60
async function sequential() {
  const started = Date.now();
  const courses = await delay(60, 'courses');
  const students = await delay(60, 'students');
  return `sequential: ${courses} + ${students} in about ${roughly(Date.now() - started)}ms`;
}

// Both at once: about 60
async function parallel() {
  const started = Date.now();
  const [courses, students] = await Promise.all([delay(60, 'courses'), delay(60, 'students')]);
  return `parallel: ${courses} + ${students} in about ${roughly(Date.now() - started)}ms`;
}

// Run one, then the other, so they do not overlap
async function compare() {
  console.log(await sequential());
  console.log(await parallel());
}

compare();
OUTPUT   TERMINAL
➜ webbo3 node app.js
sequential: courses + students in about 120ms
parallel: courses + students in about 60ms
Important Rule

Use Promise.all whenever the requests are independent. Use sequential await only when the second genuinely needs something from the first, like the orders that need the user's id.

Picture This

Picture the kettle and the toaster. If toast does not need boiling water, start them both at once.

7. Handling Failure

Definition

try { ... } catch (error) { ... } runs the try block, and if anything in it fails (including a rejected Promise you awaited), jumps to the catch block with the error. finally { ... } runs afterwards whether it worked or not.

Real-Life Example

Paying at a shop. You try your card. If it is declined, you pay with cash instead. Either way, you take your receipt and leave: that last step always happens.

What this code doesWe want to handle both outcomes of a slow task, and always clean up afterwards.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Succeeds or fails, depending on what we ask for
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 {
    // Try the slow work
    const result = await mightFail(shouldFail);
    console.log('ok:', result);
  } catch (error) {
    // Only runs if it failed
    console.log('handled:', error.message);
  } finally {
    // Always runs: the place to turn the spinner off
    console.log('finally: spinner off');
  }
}

// A success, then a failure
async function run() {
  await load(false);
  await load(true);
}

run();
OUTPUT   TERMINAL
➜ webbo3 node app.js
ok: data arrived
finally: spinner off
handled: Network unreachable
finally: spinner off
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 and concludes your app is broken. finally is where the spinner is turned off, precisely because it runs whether things worked or not.

Picture This

Picture the card machine: try the card, catch the decline and use cash, and finally take the receipt, whatever happened.

8. The Three States a Real Interface Must Have

Definition

Anything asynchronous has three possible outcomes on screen besides success, and each needs its own design: loading, error and empty.

Real-Life Example

A lift display. "Arriving" while you wait (loading), "Out of service" if it is broken (error), and if you press for a floor with nothing there, it tells you rather than going blank (empty).

StateWhat the user seesWhat you must do
LoadingA spinner, or "Loading..."Show it before the request, hide it in finally
ErrorA plain message and a way to retryCatch it, and say what happened in human words
Empty"No results for that search"Success with zero items is not an error, and not blank
What this code doesWe want the full shape every request in this course will follow, first when the request succeeds.
index.html
1
2
<p id="status"></p>
<ul id="list"></ul>
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// A fake slow request: MODE decides how it ends
const MODE = 'success';
function getCourses() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (MODE === 'error') reject(new Error('Could not reach the server'));
      else if (MODE === 'empty') resolve([]);
      else resolve(['HTML', 'CSS', 'JavaScript']);
    }, 100);
  });
}

const status = document.querySelector('#status');
const list = document.querySelector('#list');

async function load() {
  // Loading: show it before the request
  status.textContent = 'Loading...';
  try {
    const courses = await getCourses();
    // Empty: success, but nothing to show
    if (courses.length === 0) {
      status.textContent = 'No courses yet.';
      return;
    }
    // Success
    list.innerHTML = courses.map((course) => `<li>${course}</li>`).join('');
    status.textContent = `${courses.length} courses`;
  } catch (error) {
    // Error: say what happened in plain words
    status.textContent = `Sorry: ${error.message}. Please try again.`;
  }
}

load();
BROWSERindex.html · after the request finished
What this code doesWe want to see the same code when the request comes back with nothing.
index.html
1
2
<p id="status"></p>
<ul id="list"></ul>
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// A fake slow request: MODE decides how it ends
const MODE = 'empty';
function getCourses() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (MODE === 'error') reject(new Error('Could not reach the server'));
      else if (MODE === 'empty') resolve([]);
      else resolve(['HTML', 'CSS', 'JavaScript']);
    }, 100);
  });
}

const status = document.querySelector('#status');
const list = document.querySelector('#list');

async function load() {
  // Loading: show it before the request
  status.textContent = 'Loading...';
  try {
    const courses = await getCourses();
    // Empty: success, but nothing to show
    if (courses.length === 0) {
      status.textContent = 'No courses yet.';
      return;
    }
    // Success
    list.innerHTML = courses.map((course) => `<li>${course}</li>`).join('');
    status.textContent = `${courses.length} courses`;
  } catch (error) {
    // Error: say what happened in plain words
    status.textContent = `Sorry: ${error.message}. Please try again.`;
  }
}

load();
BROWSERindex.html · after an empty response
What this code doesWe want to see the same code when the request fails.
index.html
1
2
<p id="status"></p>
<ul id="list"></ul>
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// A fake slow request: MODE decides how it ends
const MODE = 'error';
function getCourses() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (MODE === 'error') reject(new Error('Could not reach the server'));
      else if (MODE === 'empty') resolve([]);
      else resolve(['HTML', 'CSS', 'JavaScript']);
    }, 100);
  });
}

const status = document.querySelector('#status');
const list = document.querySelector('#list');

async function load() {
  // Loading: show it before the request
  status.textContent = 'Loading...';
  try {
    const courses = await getCourses();
    // Empty: success, but nothing to show
    if (courses.length === 0) {
      status.textContent = 'No courses yet.';
      return;
    }
    // Success
    list.innerHTML = courses.map((course) => `<li>${course}</li>`).join('');
    status.textContent = `${courses.length} courses`;
  } catch (error) {
    // Error: say what happened in plain words
    status.textContent = `Sorry: ${error.message}. Please try again.`;
  }
}

load();
BROWSERindex.html · after the request failed
Picture This

Picture the lift display: it always tells you something true. Waiting, broken, or nothing there, the screen is never blank and never lying.

Do This Now

1. A Countdown (Very Easy)

What this code doesExercise 1: use await to space three messages apart.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Log one, two, three with a pause between each
async function count() {
  console.log('one');
  await delay(200);
  console.log('two');
  await delay(200);
  console.log('three');
}

count();
OUTPUT   TERMINAL
➜ webbo3 node app.js
one
two
three

2. Three Downloads at Once (Simple Application)

What this code doesExercise 2: run independent work together with Promise.all.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
const delay = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));

// Start three "downloads" together and wait for all of them
async function downloadAll() {
  const files = await Promise.all([
    delay(90, 'notes.pdf'),
    delay(30, 'slides.pdf'),
    delay(60, 'video.mp4')
  ]);
  console.log('all done:', files);
}

downloadAll();
OUTPUT   TERMINAL
➜ webbo3 node app.js
all done: [ 'notes.pdf', 'slides.pdf', 'video.mp4' ]

Notice the results come back in the order you asked for them, not the order they finished.

3. Predict the Order (Combination)

A Promise that is already settled, Promise.resolve(), still runs its then later, but before any timer.

What this code doesExercise 3: predict the order of a timer and an already-settled Promise before running it.
app.js
1
2
3
4
5
6
7
console.log('A');

setTimeout(() => console.log('B: timer'), 0);

Promise.resolve().then(() => console.log('C: promise'));

console.log('D');
OUTPUT   TERMINAL
➜ webbo3 node app.js
A
D
C: promise
B: timer

Promises have their own queue, which JavaScript empties before it looks at the timer queue. You do not need to memorise that; you need to know that the written order is not the running order.

4. Retry Until It Works (Small Real-World Problem)

for (let attempt = 1; attempt <= 5; attempt = attempt + 1) is the counting loop: start at 1, keep going while the condition is true, and add 1 after each pass.

What this code doesExercise 4: combine a loop, await and try/catch to retry a request that fails at first.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Fails the first two times, then succeeds: a flaky network
let attempts = 0;
function flakyRequest() {
  attempts = attempts + 1;
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (attempts < 3) reject(new Error(`attempt ${attempts} failed`));
      else resolve('data arrived');
    }, 20);
  });
}

// Try up to five times, stopping as soon as one works
async function loadWithRetry() {
  for (let attempt = 1; attempt <= 5; attempt = attempt + 1) {
    try {
      const result = await flakyRequest();
      console.log('success:', result);
      return;
    } catch (error) {
      console.log('retrying after', error.message);
    }
  }
  console.log('gave up');
}

loadWithRetry();
OUTPUT   TERMINAL
➜ webbo3 node app.js
retrying after attempt 1 failed
retrying after attempt 2 failed
success: data arrived

Checkpoint

Q1. Why does setTimeout(fn, 0) still run after the last line of the file?

It is queued, not run. JavaScript finishes all the work it already has before taking anything off the queue, so zero milliseconds means "as soon as I am completely free".

Q2. Your console prints a Promise instead of a value. What did you forget?

To await it, or to attach .then(). You printed the Promise object itself, not the value it will 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 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 block?

finally runs whether the request succeeded or failed. Put it anywhere else and a failed request leaves the spinner turning forever.

Tick Before You Move On

  • ☐ I predicted the timer example correctly before running it
  • ☐ I can explain why a function cannot return a value that has not arrived
  • ☐ I wrote a Promise by hand with both resolve and reject
  • ☐ I used async and await, and never printed a Promise by accident
  • ☐ I timed sequential awaits against Promise.all and saw the difference
  • ☐ Every async function I wrote today has a catch and a finally
  • ☐ I designed loading, error and empty states for one request

Quick Recap

JavaScript has one thread and never stops to wait; slow work is handed out and comes back through a queue. You cannot return a value that has not arrived yet. Callbacks solved it but nested badly; Promises flattened the nesting with then and catch. async and await make waiting code read top to bottom, pausing only their own function. Promise.all for independent work, sequential await only when one step needs the last. try, catch and finally handle failure, and loading, error and empty are states you must design.

Final Scenario

Tomorrow your code will fetch real course data from a server. That request is slow, it can fail, and it might come back empty.

You already have the shape for all of it: an async function, await for the response, try and catch for failure, finally to stop the spinner, and a separate message for "nothing here". The only new thing tomorrow is where the data comes from.

Every data-driven screen you will ever build follows this shape. Learn it once, and every request after tomorrow is the same few lines.

Tomorrow, Day 8: the thing you have been waiting for. Real HTTP requests to real APIs, and what all those status codes mean.

Learn this with a real instructor

Free lessons take you a long way on your own. The full programmes add live classes, assignments, Skill Points and a certificate — built for African students.