Fetch API & Async JavaScript
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.
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".
// 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... }); }); });
(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 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
.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.
// 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 }; }
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.
// 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() 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().
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'); }
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.
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.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.await keyword do inside an async function?fetch(url) use by default?Promise.all([p1, p2, p3]) do?.json() on a fetch response, what should you check first?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
asynckeyword - 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
jokeproperty (for single-part jokes) - Set
document.getElementById('joke-output').textContent = data.joke - In the
catchblock, set the same div toerr.message