Lesson 148 min read

Async Programming

Handle things that take time — like fetching data — without freezing your app

The Problem: JavaScript Is Single-Threaded

JavaScript runs on one thread — it can only do one thing at a time, processing tasks through an event loop backed by a queue. But what about tasks that take a while, like fetching data from a server, reading a file, or waiting for a timer? If JavaScript had to sit and wait for each one, your entire app would freeze.

The solution? Asynchronous programming. Instead of waiting, JavaScript says "start this task, and let me know when it's done — I'll keep doing other things in the meantime." It's like ordering food at a restaurant — you don't stand at the kitchen door waiting. You sit down, chat with friends, and the waiter brings your food when it's ready.

JavaScript's async story evolved over the years: callbacks (the old way) → Promises (the better way) → async/await (the cleanest way).

How the event loop handles async operations: the main thread delegates work to Web APIs, continues executing, and the event loop pushes callbacks back when the call stack is empty.

Callbacks — The Original Approach

// setTimeout — the simplest async operation
console.log("1. Start");
setTimeout(() => {
console.log("2. This runs after 1 second");
}, 1000);
console.log("3. End (runs BEFORE the timeout!)");
// Callback pattern — pass a function to run when done
function fetchUser(id, callback) {
setTimeout(() => {
const user = { id, name: "Luna", level: 42 };
callback(user);
}, 500);
}
fetchUser(1, (user) => {
console.log(`Got user: ${user.name}`);
});
// Callback hell — nested callbacks get ugly fast
// fetchUser(1, (user) => {
// fetchPosts(user.id, (posts) => {
// fetchComments(posts[0].id, (comments) => {
// // 😱 This is called "callback hell" or "pyramid of doom"
// });
// });
// });
Output
1. Start
3. End (runs BEFORE the timeout!)
Got user: Luna
2. This runs after 1 second

Promises — A Better Way

A Promise is an object that represents a value that might not exist yet. Think of it like a gift card — it's a promise that you'll get something in the future. A Promise can be in one of three states:

  • Pending — the operation is still running
  • Fulfilled — the operation completed successfully (you can access the result)
  • Rejected — the operation failed (you can access the error)

You use .then() to handle success and .catch() to handle errors. The best part? Promises can be chained, so you avoid the nested callback nightmare.

Creating & Chaining Promises

// Creating a Promise
function delay(ms) {
return new Promise((resolve) => {
setTimeout(() => resolve(`Done after ${ms}ms`), ms);
});
}
delay(1000).then(msg => console.log(msg));
// A Promise that can fail
function fetchScore(playerId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (playerId > 0) {
resolve({ playerId, score: 9500 });
} else {
reject(new Error("Invalid player ID"));
}
}, 300);
});
}
// .then() for success, .catch() for errors
fetchScore(1)
.then(data => console.log(`Score: ${data.score}`))
.catch(err => console.log(`Error: ${err.message}`));
fetchScore(-1)
.then(data => console.log(`Score: ${data.score}`))
.catch(err => console.log(`Error: ${err.message}`));
Output
Done after 1000ms
Score: 9500
Error: Invalid player ID

Async/Await — The Modern Way

async/await is syntactic sugar on top of Promises that makes async code look and feel like synchronous code. An async function always returns a Promise. Inside it, you can use await to pause execution until a Promise settles.

It's like turning the restaurant analogy into a story: "I await my appetizer. Then I await my main course. Then I await the bill." Each step waits for the previous one to finish, but the rest of the restaurant keeps running.

Async/Await & Fetch API

// async/await — clean and readable
async function getPlayerInfo(id) {
try {
const response = await fetch(`https://api.example.com/players/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const player = await response.json();
console.log(`Player: ${player.name}, Level: ${player.level}`);
return player;
} catch (error) {
console.log(`Failed to fetch player: ${error.message}`);
return null;
}
}
// Call it
getPlayerInfo(42);
// Promise.all — run multiple async tasks in PARALLEL
async function loadDashboard() {
try {
const [users, posts, stats] = await Promise.all([
fetch("/api/users").then(r => r.json()),
fetch("/api/posts").then(r => r.json()),
fetch("/api/stats").then(r => r.json())
]);
console.log(`Loaded: ${users.length} users, ${posts.length} posts`);
return { users, posts, stats };
} catch (error) {
console.log("One of the requests failed!");
}
}
// Promise.allSettled — don't fail if ONE fails
async function loadData() {
const results = await Promise.allSettled([
fetch("/api/critical").then(r => r.json()),
fetch("/api/optional").then(r => r.json())
]);
results.forEach((result, i) => {
if (result.status === "fulfilled") {
console.log(`Request ${i}: Success`);
} else {
console.log(`Request ${i}: Failed — ${result.reason}`);
}
});
}
Output
Player: Luna, Level: 42
Loaded: 50 users, 120 posts
Request 0: Success
Request 1: Failed — TypeError: Failed to fetch
Note: Common mistake: forgetting 'await' before an async call. Without await, you get the Promise object instead of its value — and your code runs ahead before the data is ready. If you see [object Promise] in your output, you probably forgot an await!

Quick check

What does an async function always return?

Continue reading

QueueData Structure
FIFO — array & linked-list backed
Message QueuesSystem Design
Decouple services and handle traffic spikes gracefully
Decorators & ClosuresPython
Wrap functions to add superpowers
Modules & ImportsClosures & Scope