Lecture 15: Asynchronous JavaScript: Promises and Fetch API¶
So far, every code example in this unit has run top to bottom, one line finishing before the next begins. But real web pages constantly wait on things that take time — a file loading, a timer, or a response from a server — without freezing the whole page while they wait. This lecture explains how JavaScript manages that waiting, and how to fetch real data from a server.
In This Lecture¶
- Synchronous vs. asynchronous execution, and the event loop, task queue, and "callback hell"
- Promises: their three states,
.then/.catch/.finally, chaining, andPromise.all async/await, and error handling withtry...catch- Making real network requests with the Fetch API: GET/POST, headers, response parsing, and CORS
Synchronous vs. Asynchronous Execution¶
Synchronous code runs one instruction at a time, in order, and each instruction must finish before the next one starts. This is how almost all the code you've written so far behaves.
console.log("1");
console.log("2");
console.log("3");
// Always prints: 1, 2, 3 — in that exact order, with no gaps
Asynchronous code lets some operations happen "in the background" without blocking the rest of the program. JavaScript starts the operation, moves on immediately to the next line, and comes back to handle the result once it's ready.
console.log("1");
setTimeout(() => console.log("2"), 1000); // scheduled for later, doesn't block
console.log("3");
// Prints: 1, 3, 2 — "2" appears about a second later!
This matters because JavaScript in the browser runs on a single thread — it can only do one thing at a time. If a network request or a large computation blocked that one thread, the entire page (buttons, scrolling, animations) would freeze until it finished. Asynchronous operations (timers, network requests, file reads) are handed off so the single thread stays free to keep the page responsive.
The Event Loop and Task Queue¶
JavaScript manages this with a mechanism called the event loop:
- The call stack is where currently-running code executes, one function call at a time.
- When you start an asynchronous operation (like
setTimeoutor a network request), it is handed off to the browser, which handles the actual waiting outside of JavaScript. - When that operation finishes, its callback function is placed into a task queue (also called the callback queue, or a separate microtask queue for Promises specifically).
- The event loop constantly checks: is the call stack empty? If yes, it takes the next item from the queue and pushes it onto the call stack to run.
sequenceDiagram
participant Stack as Call Stack
participant Browser as Browser / Web APIs
participant Queue as Task Queue
participant Loop as Event Loop
Stack->>Stack: console.log("1")
Stack->>Browser: setTimeout(fn, 1000) handed off
Stack->>Stack: console.log("3")
Note over Stack: Call stack now empty
Browser->>Queue: after 1000ms, fn is queued
Loop->>Queue: checks queue (stack is empty)
Queue->>Stack: fn moved to call stack
Stack->>Stack: console.log("2") runs
Microtasks run before macrotasks
Promise callbacks (.then, async/await) go into a special microtask queue that the
event loop always empties completely before it looks at the regular task queue (where
setTimeout callbacks live). This is why a Promise.resolve().then(...) scheduled after a
setTimeout(..., 0) usually still runs first.
Callback Hell¶
Before Promises existed, asynchronous code was handled with callbacks — functions passed in to be called later. When one async step depends on the result of the previous one, callbacks nest deeper and deeper, producing hard-to-read, hard-to-maintain code nicknamed callback hell (or the "pyramid of doom"):
getUser(userId, function (user) {
getOrders(user.id, function (orders) {
getOrderDetails(orders[0].id, function (details) {
console.log(details);
// ...and it keeps growing to the right with every extra step
}, handleError);
}, handleError);
}, handleError);
Promises (and later async/await) were introduced specifically to solve this problem.
Promises¶
A Promise is an object representing a value that isn't available yet, but will be at some point — either successfully, or with an error. Think of it as a receipt for a value that's still being prepared.
The Three States¶
stateDiagram-v2
[*] --> Pending: Promise created
Pending --> Fulfilled: operation succeeds
Pending --> Rejected: operation fails
Fulfilled --> [*]
Rejected --> [*]
- Pending: the initial state — neither succeeded nor failed yet.
- Fulfilled: the operation completed successfully, and the promise now holds a result value.
- Rejected: the operation failed, and the promise now holds an error/reason.
A promise is settled once it becomes fulfilled or rejected, and it can never change state again after that.
Creating and Using a Promise¶
function delay(ms) {
return new Promise((resolve, reject) => {
if (ms < 0) {
reject(new Error("Delay cannot be negative"));
return;
}
setTimeout(() => resolve(`Waited ${ms}ms`), ms);
});
}
delay(1000)
.then(result => console.log(result)) // runs if the promise is fulfilled
.catch(error => console.error(error)) // runs if the promise is rejected
.finally(() => console.log("Done, either way")); // always runs
.then(onFulfilled)registers a callback for when the promise succeeds..catch(onRejected)registers a callback for when the promise fails..finally(callback)runs regardless of success or failure — great for cleanup, like hiding a loading spinner.
Chaining Promises¶
Each .then() returns a new promise, which is what makes chaining possible — this replaces
the nested callback pyramid from before with a flat, readable sequence:
getUser(userId)
.then(user => getOrders(user.id)) // runs after getUser resolves
.then(orders => getOrderDetails(orders[0].id)) // runs after getOrders resolves
.then(details => console.log(details)) // runs after getOrderDetails resolves
.catch(error => console.error("Something failed:", error)); // catches ANY failure above
One .catch() at the end catches an error from any step in the chain — you don't need to
handle errors after every single step.
Promise.all¶
Promise.all takes an array of promises and returns a single new promise that fulfills only
once all of them have fulfilled (with an array of their results, in the same order) — or
rejects immediately if any one of them rejects.
const promise1 = delay(1000).then(() => "First");
const promise2 = delay(500).then(() => "Second");
const promise3 = delay(1500).then(() => "Third");
Promise.all([promise1, promise2, promise3]).then(results => {
console.log(results); // ["First", "Second", "Third"] — after ~1500ms total, not 3000ms
});
Promise.all is ideal when you need several independent pieces of data before you can continue —
for example, loading a user's profile, their posts, and their friends list all at once, in
parallel, instead of one after another.
async/await¶
async/await is syntax (introduced in ES2017) built on top of Promises that lets
asynchronous code read like synchronous code, while still being non-blocking underneath.
async function loadOrderDetails(userId) {
const user = await getUser(userId); // "pause" here until the promise settles
const orders = await getOrders(user.id);
const details = await getOrderDetails(orders[0].id);
return details;
}
Two rules:
asyncbefore a function makes it always return a Promise, and allowsawaitinside it.awaitpauses execution of that function (not the whole program) until the promise it's waiting on settles, then "unwraps" the resolved value — or throws if the promise rejected.
Error Handling with try...catch¶
Because await can throw when a promise rejects, you handle errors with an ordinary
try...catch block, exactly like handling any other exception:
async function loadOrderDetails(userId) {
try {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const details = await getOrderDetails(orders[0].id);
return details;
} catch (error) {
console.error("Failed to load order details:", error.message);
throw error; // re-throw if the caller also needs to know
}
}
async/await vs. .then chains
They do the same job — async/await is just easier to read, especially with multiple
sequential steps and conditional logic. You will see both styles in real codebases, so learn
to recognize each, but prefer async/await for new code you write.
AJAX and the Fetch API¶
AJAX (Asynchronous JavaScript and XML — the name is historical; today it almost always means JSON, not XML) is the general technique of a web page requesting data from a server in the background, without a full page reload. The modern, built-in tool for doing this is the Fetch API.
A GET Request¶
async function loadStudents() {
const response = await fetch("https://api.example.com/students");
const students = await response.json(); // parses the response body as JSON
console.log(students);
}
fetch(url) returns a Promise that resolves with a Response object once the server has
responded with headers — note that the promise resolves even for error responses like 404; it
only rejects on a genuine network failure (like no internet connection). That's why you check
response.ok or response.status yourself:
async function loadStudents() {
const response = await fetch("https://api.example.com/students");
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const students = await response.json();
return students;
}
A POST Request with Headers¶
async function createStudent(newStudent) {
const response = await fetch("https://api.example.com/students", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(newStudent), // JS object -> JSON text, from Lecture 14
});
if (!response.ok) {
throw new Error(`Failed to create student: ${response.status}`);
}
return response.json();
}
createStudent({ name: "Hina", age: 20 })
.then(created => console.log("Created:", created))
.catch(error => console.error(error));
Headers are extra metadata sent with a request or response — Content-Type: application/json
tells the server "the body I'm sending you is JSON text," so it knows how to parse it.
Parsing Different Response Types¶
const response = await fetch("/data");
const asJson = await response.json(); // parse as JSON
// const asText = await response.text(); // parse as plain text
// const asBlob = await response.blob(); // parse as raw binary data (e.g. an image)
You can only read a response body once — call one parsing method per response.
Putting It All Together¶
async function fetchAllPages() {
try {
const [usersRes, postsRes] = await Promise.all([
fetch("https://api.example.com/users"),
fetch("https://api.example.com/posts"),
]);
const users = await usersRes.json();
const posts = await postsRes.json();
console.log(users, posts);
} catch (error) {
console.error("Network error:", error.message);
}
}
CORS Basics¶
CORS (Cross-Origin Resource Sharing) is a browser security rule that restricts JavaScript
running on one origin (a combination of protocol + domain + port, e.g.
https://myapp.com) from freely reading responses from a different origin
(e.g. https://api.example.com), unless that other server explicitly allows it.
sequenceDiagram
participant Browser
participant API as api.example.com
Browser->>API: fetch("https://api.example.com/data")
API-->>Browser: response + header "Access-Control-Allow-Origin: https://myapp.com"
Note over Browser: Browser checks the header.<br/>If myapp.com is allowed, JS can read the response.<br/>If not, the browser blocks JS from reading it.
- The server decides who is allowed, by sending back an
Access-Control-Allow-Originheader. - If that header doesn't permit your page's origin, the browser blocks your JavaScript from reading the response — you'll see a CORS error in the console, even though the network request itself may have technically succeeded.
- CORS is enforced by the browser, not by your JavaScript code — you cannot "fix" a CORS error from the frontend; the server must be configured to allow your origin.
CORS is not something you can bypass from the client
If you see a CORS error, the fix belongs on the server (adding the right
Access-Control-Allow-Origin header), not in your fetch call. You'll work with server
configuration, including CORS, starting in Unit 5.
Try It Yourself¶
- Write an
asyncfunctiongetRandomJoke()that fetches data from a public API of your choice (for examplehttps://official-joke-api.appspot.com/random_joke), parses the JSON response, and returns just the joke text. Wrap the network call intry...catchand log a friendly error message if the request fails. - Using
Promise.all, write a function that fetches two different endpoints at the same time and logs both results only once both have arrived. Then deliberately point one URL at a non-existent server and observe how the.catch(ortry...catch) handles the failure of the whole group.
Key Takeaways¶
- Synchronous code blocks the single JavaScript thread until each line finishes; asynchronous code lets long-running operations happen in the background without freezing the page.
- The event loop constantly checks whether the call stack is empty, and if so, moves the next
item from the task/microtask queue onto it — this is how
setTimeoutand Promise callbacks eventually run. - A Promise moves through three states — pending, fulfilled, rejected — and is handled with
.then,.catch, and.finally; chaining.thencalls replaces nested "callback hell." Promise.allruns multiple promises in parallel and waits for all of them (or fails fast if any one rejects).async/awaitis Promise-based syntax that reads like synchronous code; errors are handled with ordinarytry...catch.- The Fetch API makes network requests; check
response.okbefore parsing, sincefetchonly rejects on true network failure, not on HTTP error status codes. - CORS is a browser security rule enforced by checking response headers from the server — it cannot be fixed from client-side JavaScript alone.