🎉
Lesson 12 Complete!
You've mastered the Fetch API and async JavaScript. One final step before you're done — the capstone projects.
⚡ Phase 2 · Real-World JS 🔴 Advanced MODULE 12

Fetch API & Async JavaScript

40 min read
📚 7 Sections
🧩 5 Quiz Questions
🏗 1 Challenge
Course progress — Phase 292%
🎯 What you'll learn: Modern web apps live and die by their ability to talk to servers. In this lesson you'll master every layer of async JavaScript — from the old-school callbacks that started it all, through Promises, to the clean async/await syntax used in professional code today. Then you'll put it all together with the Fetch API to read and write real data from public APIs.

Why Async Matters

JavaScript runs in a single thread — it can only do one thing at a time. If you write code that blocks this thread (say, waiting 3 seconds for a server response), the entire browser freezes. Buttons stop responding. Animations stutter. The user leaves.

Asynchronous code solves this. Instead of waiting for a slow operation to finish, JavaScript says "start that task and come back when it's done" and immediately moves on to the next thing.

🍕
Synchronous (Blocking)
Like cooking every ingredient yourself before serving any food. The restaurant grinds to a halt.
🍴
Asynchronous (Non-Blocking)
Like a waiter who takes your order, delivers it to the kitchen, and serves other tables while your food cooks.
🔌
Network Requests
Fetching data from an API can take 50ms to 5 seconds. Never block the main thread for this.
The Event Loop
JS uses an event loop to handle async tasks. Completed async work gets queued and processed when the thread is free.
💡
The Three Eras of Async JS
JavaScript async has evolved through three distinct patterns: Callbacks (ES5, ~2009), Promises (ES6, 2015), and async/await (ES2017). Each solved the problems of the previous. Modern code uses async/await almost exclusively, but you need to understand all three because you'll encounter all of them in real codebases.

Callbacks

A callback is a function you pass as an argument to another function, to be called once an async operation completes. They were the original solution to async JavaScript and are still used today in event listeners and array methods like forEach.

The problem arises when you need to chain multiple async operations. Each one requires a callback inside the previous one, creating a deeply nested structure nicknamed "Callback Hell" or the "Pyramid of Doom".

Callback Hell — the Pyramid of DoomJS
// Simulating async operations with setTimeout
function getUser(id, callback) {
  setTimeout(() => {
    callback({ id, name: 'Alice' });
  }, 500);
}

function getOrders(userId, callback) {
  setTimeout(() => {
    callback([{ id: 1, item: 'Book' }, { id: 2, item: 'Pen' }]);
  }, 400);
}

function getShipping(orderId, callback) {
  setTimeout(() => {
    callback({ status: 'delivered', date: '2026-06-10' });
  }, 300);
}

// Callback Hell: every level adds another nest
getUser(1, (user) => {
  console.log('User:', user.name);
  getOrders(user.id, (orders) => {
    console.log('Orders:', orders.length);
    getShipping(orders[0].id, (shipping) => {
      console.log('Status:', shipping.status);
      // Imagine needing one more level... and another...
    });
  });
});
Error Handling in Callbacks
Each level needs its own error parameter (the Node.js convention is (err, data)). Forgetting to handle an error at any level means silent failures. This is another reason Promises were invented.

Promises

A Promise is an object representing the eventual completion or failure of an async operation. It's a placeholder for a value that doesn't exist yet. Promises have three states: pending (initial, operation in progress), fulfilled (succeeded, has a value), and rejected (failed, has a reason).

Promises let you chain operations with .then(), catch errors at the end of the chain with .catch(), and run cleanup code with .finally() — turning the pyramid into a readable sequence.

Creating and Chaining PromisesJS
// Creating a Promise manually
const myPromise = new Promise((resolve, reject) => {
  const success = true;
  if (success) {
    resolve('Data loaded!');   // fulfilled
  } else {
    reject(new Error('Something went wrong'));  // rejected
  }
});

// Consuming a Promise
myPromise
  .then((value) => {
    console.log(value);          // 'Data loaded!'
    return value + ' Processed.';  // pass to next .then()
  })
  .then((processed) => {
    console.log(processed);     // 'Data loaded! Processed.'
  })
  .catch((err) => {
    console.error('Error:', err.message);  // catches any rejection above
  })
  .finally(() => {
    console.log('Always runs — like hiding a loading spinner');
  });

// Promise.resolve / Promise.reject shorthand
Promise.resolve(42).then(v => console.log(v));   // 42
Promise.reject('oops').catch(e => console.log(e)); // oops
💡
Returning Promises in .then()
When you return a value from a .then() handler, the next .then() receives it as its argument. When you return another Promise, the chain waits for that promise to settle before proceeding. This is how you chain async calls without nesting.

async / await

async/await is syntactic sugar built on top of Promises. It lets you write async code that looks synchronous, which is far easier to read and debug. An async function always returns a Promise. The await keyword pauses execution inside that function until the awaited Promise settles — without blocking the main thread.

async/await — the Modern StandardJS
// Mark the function with 'async'
async function loadUserData() {
  // 'await' pauses HERE until the promise resolves
  const user = await getUser(1);
  console.log(user.name);         // Alice

  const orders = await getOrders(user.id);
  console.log(orders.length);      // 2

  const shipping = await getShipping(orders[0].id);
  console.log(shipping.status);    // delivered

  return { user, orders, shipping }; // returns a Promise that resolves to this object
}

// Call it like any Promise
loadUserData().then(data => console.log(data));

// ─────────────────────────────────────────────────
// Parallel Execution with Promise.all()
// ─────────────────────────────────────────────────
async function loadDashboard() {
  // BAD: sequential (waits for each before starting the next)
  const users  = await fetchUsers();
  const posts  = await fetchPosts();
  const stats  = await fetchStats();

  // GOOD: parallel (all three fire simultaneously)
  const [users2, posts2, stats2] = await Promise.all([
    fetchUsers(),
    fetchPosts(),
    fetchStats(),
  ]);

  return { users2, posts2, stats2 };
}
💡
Arrow Functions Can Be async Too
You can write const load = async () => { ... } or use async arrow functions as callbacks. Top-level await (outside any function) is also now supported in ES modules.

The Fetch API

The Fetch API is the browser's built-in tool for making HTTP requests. It replaced the older XMLHttpRequest and returns a Promise, making it perfect to use with async/await.

Calling fetch(url) returns a Promise that resolves to a Response object. The Response object contains status codes, headers, and methods to read the body. You must call response.json() or response.text() to extract the actual data — and those methods also return Promises.

GET Request — Fetching JSON from an APIJS
// fetch() returns Promise<Response>
async function getPost() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');

  // response.ok is true if status is 200-299
  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }

  // .json() reads the response body and parses it as JSON
  const post = await response.json();

  console.log(post.title);   // 'sunt aut facere repellat...'
  console.log(post.body);    // post body text
  console.log(post.userId);  // 1

  return post;
}

getPost().then(data => console.log('Received:', data));

// ─────────────────────────────────────────────────
// The Response Object — key properties
// ─────────────────────────────────────────────────
const r = await fetch('https://jsonplaceholder.typicode.com/posts');

console.log(r.ok);          // true (status 200-299)
console.log(r.status);      // 200
console.log(r.statusText);  // "OK"
console.log(r.url);         // full URL string
r.headers.get('content-type');  // "application/json; charset=utf-8"

const allPosts = await r.json();   // array of 100 posts
console.log(allPosts.length);       // 100
Fetch Does NOT Reject on HTTP Errors
A fetch() Promise only rejects on network failures (no internet, DNS failure). A 404 or 500 response still counts as "fulfilled"! You must manually check response.ok or response.status to detect HTTP errors. This trips up many beginners.

POST Requests

By default fetch() makes a GET request. To send data to a server you use POST (or PUT, PATCH, DELETE) and pass a configuration object as the second argument. You set the HTTP method, specify Content-Type in the headers, and serialize your data with JSON.stringify().

POST Request — Creating a ResourceJS
async function createPost(title, body) {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      title,
      body,
      userId: 1,
    }),
  });

  if (!response.ok) {
    throw new Error(`Failed to create post: ${response.status}`);
  }

  const newPost = await response.json();
  console.log('Created:', newPost);
  // { title: '...', body: '...', userId: 1, id: 101 }
  return newPost;
}

createPost('My First Post', 'Hello from the Fetch API!');

// ─────────────────────────────────────────────────
// PUT and DELETE follow the same pattern
// ─────────────────────────────────────────────────
async function deletePost(id) {
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
    method: 'DELETE',
  });
  if (response.ok) console.log('Deleted successfully');
}
💡
Always Set Content-Type for POST
When sending JSON, you must set the Content-Type: application/json header. Otherwise the server may not know how to parse your body and will respond with a 400 Bad Request or silently ignore your data.

Error Handling

Robust async code handles failures gracefully. With async/await, you wrap your operations in a try/catch/finally block. The try block contains the happy path; catch receives any thrown errors (including rejected Promises); finally always runs — perfect for hiding loading spinners or releasing resources.

Robust Error Handling with try/catch/finallyJS
async function fetchUserProfile(userId) {
  const spinner = document.getElementById('spinner');
  spinner.style.display = 'block';

  try {
    const response = await fetch(`https://api.example.com/users/${userId}`);

    // Network succeeded but server returned an error code
    if (!response.ok) {
      if (response.status === 404) {
        throw new Error('User not found');
      }
      throw new Error(`Server error: ${response.status}`);
    }

    const user = await response.json();
    displayUser(user);
    return user;

  } catch (err) {
    // Catches: network failures AND errors we threw above
    console.error('Failed to load user:', err.message);
    showErrorMessage(err.message);
    return null;

  } finally {
    // Runs whether we succeeded or failed
    spinner.style.display = 'none';
  }
}

// ─────────────────────────────────────────────────
// Parallel requests with individual error handling
// ─────────────────────────────────────────────────
async function loadAll() {
  const results = await Promise.allSettled([
    fetch('https://api.example.com/posts'),
    fetch('https://api.example.com/users'),
  ]);

  results.forEach((result, i) => {
    if (result.status === 'fulfilled') {
      console.log(`Request ${i} succeeded`);
    } else {
      console.log(`Request ${i} failed:`, result.reason);
    }
  });
}

Callbacks vs Promises vs async/await

Feature Callbacks Promises async/await
Introduced ES5 (2009) ES6 (2015) ES2017 (2017)
Readability Poor (nesting) Good (chaining) Excellent (linear)
Error handling Manual per-level .catch() at end try/catch block
Debugging Hard (stack trace) Moderate Easy (reads like sync)
Parallel tasks Very complex Promise.all() await Promise.all()
Use today? Event listeners only When needed Yes — primary choice
🚀
Promise.allSettled() vs Promise.all()
Promise.all() rejects immediately if any promise rejects. Promise.allSettled() waits for all promises to finish and gives you an array of results, each with a status of 'fulfilled' or 'rejected'. Use allSettled() when you want all results regardless of individual failures.
🧩 Knowledge Check
5 questions — click an answer to check it immediately
1. What does the await keyword do inside an async function?
2. What HTTP method does fetch(url) use by default?
3. Which method converts a Fetch Response body into a JavaScript object?
4. What does Promise.all([p1, p2, p3]) do?
5. Before calling .json() on a fetch response, what should you check first?
🎉
Nice work!
You've tested your knowledge of async JavaScript and the Fetch API.
💻
Coding Challenge: Random Programming Joke
Apply what you've learned — write real async code
Your task: Write an async function called fetchJoke() that fetches a random programming joke from the JokeAPI:

https://v2.jokeapi.dev/joke/Programming?type=single

When the function runs it should: (1) fetch the joke, (2) check that response.ok is true, (3) parse the JSON response, (4) find the joke property in the response, and (5) display it in a <div id="joke-output"> on the page. Wrap everything in try/catch so errors are shown in the same div instead of crashing silently.
💡 Show Hints
  • Mark your function with the async keyword
  • Use await fetch(url) to get the response
  • Check if (!response.ok) throw new Error(...)
  • Use await response.json() to parse the body
  • The API returns an object with a joke property (for single-part jokes)
  • Set document.getElementById('joke-output').textContent = data.joke
  • In the catch block, set the same div to err.message