Asynchronous code that reads like synchronous code — because sometimes the best upgrade is better syntax.
We've come a long way. Callbacks taught us how JavaScript handles async operations. Promises gave us flat chains and centralized error handling. But let me show you the next evolution:
// Promise chain
getUser(1)
.then((user) => getOrders(user.id))
.then((orders) => getShipping(orders[0].id))
.then((shipping) => console.log(shipping.address))
.catch((err) => console.log(err));
// Async/await — same logic
async function showShippingAddress() {
try {
const user = await getUser(1);
const orders = await getOrders(user.id);
const shipping = await getShipping(orders[0].id);
console.log(shipping.address);
} catch (err) {
console.log(err);
}
}
Look at the async/await version. No .then(). No chaining. No callbacks. It reads exactly like synchronous code — step 1, step 2, step 3 — but it's fully asynchronous under the hood.
When I first saw this in the ChaiCode Web Dev Cohort 2026, I thought it was too good to be true. But it's real, and it's now the standard way to write async JavaScript. Let me break it down.
Why Was Async/Await Introduced?
Promises were a massive improvement over callbacks. But they still have a learning curve. .then() chains, returning values between steps, understanding how .catch() propagates — it's all manageable, but it's not intuitive.
The JavaScript community asked: "What if we could write async code that looks like regular, top-to-bottom, synchronous code?"
That's exactly what async/await is. It was introduced in ES2017 (ES8) as syntactic sugar on top of Promises. It doesn't replace Promises — it uses them. Every async function returns a Promise. Every await unwraps a Promise. It's Promises with a friendlier face.
The Evolution
Callbacks (ES5):
getUser(1, (err, user) => {
getOrders(user.id, (err, orders) => {
getShipping(orders[0].id, (err, ship) => {
console.log(ship.address);
});
});
});
Promises (ES6):
getUser(1)
.then(user => getOrders(user.id))
.then(orders => getShipping(orders[0].id))
.then(ship => console.log(ship.address))
.catch(err => console.log(err));
Async/Await (ES2017):
const user = await getUser(1);
const orders = await getOrders(user.id);
const shipping = await getShipping(orders[0].id);
console.log(shipping.address);
Same async operations. Each version is cleaner than the last.
How Async Functions Work
An async function is a function declared with the async keyword. Two things happen when you add async:
- The function automatically returns a Promise
- You're allowed to use the
awaitkeyword inside it
Basic Syntax
async function fetchData() {
return "Hello from async!";
}
// This is identical to:
function fetchData() {
return Promise.resolve("Hello from async!");
}
Whatever you return from an async function gets wrapped in a resolved Promise automatically.
async function getName() {
return "Pratham";
}
getName().then((name) => console.log(name)); // "Pratham"
You can also use arrow function syntax:
const getName = async () => {
return "Pratham";
};
Proof That Async Functions Return Promises
async function example() {
return 42;
}
const result = example();
console.log(result); // Promise { <fulfilled>: 42 }
console.log(result instanceof Promise); // true
It's a Promise. Always. Even if you return a plain value, it gets wrapped.
The await Keyword
await is the magic word. It pauses the execution of the async function until the Promise it's waiting on settles (fulfills or rejects). Then it unwraps the result and gives you the actual value.
Without await
async function getUser() {
const response = fetch("https://jsonplaceholder.typicode.com/users/1");
console.log(response); // Promise { <pending> } — not the data!
}
With await
async function getUser() {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users/1",
);
const user = await response.json();
console.log(user.name); // "Leanne Graham" — the actual data!
}
getUser();
await says: "Pause here. Wait for this Promise to resolve. Then give me the value."
Key Rules for await
-
awaitcan only be used insideasyncfunctions (or at the top level of a module) -
awaitpauses the async function, not the entire program — other code keeps running -
awaitworks with any Promise — built-in ones likefetch()or your own
await Doesn't Block Everything
This is crucial to understand. When an async function hits await, it pauses that function and gives control back to the rest of the program. Other code continues running.
async function slowTask() {
console.log("A: Starting slow task...");
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log("B: Slow task done!");
}
console.log("1: Before");
slowTask();
console.log("2: After");
Output:
1: Before
A: Starting slow task...
2: After
B: Slow task done! ← 2 seconds later
"2: After" prints before "B: Slow task done!" because await only pauses the async function, not the calling code.
Async Function Execution Flow
console.log("Start");
async function doWork() {
console.log("Step 1");
const data = await fetchSomething(); ← pauses HERE
console.log("Step 2:", data); ← runs after await resolves
}
doWork();
console.log("End");
Execution:
─────────────────────────────────────────────────
"Start" — synchronous
"Step 1" — synchronous (inside async, before await)
"End" — synchronous (doWork paused at await, control returned)
...waiting for fetchSomething()...
"Step 2: data" — runs when the Promise resolves
─────────────────────────────────────────────────
Key insight: Everything BEFORE the first await runs synchronously.
At the first await, the function pauses and control returns to the caller.
Error Handling with try/catch
With Promises, you use .catch(). With async/await, you use try/catch — the same error handling syntax you use for synchronous code.
Basic Error Handling
async function fetchUser(userId) {
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const user = await response.json();
console.log(`User: ${user.name}`);
} catch (error) {
console.log(`Failed to fetch user: ${error.message}`);
}
}
fetchUser(1);
If anything inside the try block throws an error or if any awaited Promise rejects, execution immediately jumps to the catch block.
With finally
async function loadData() {
console.log("⏳ Loading...");
try {
const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
const post = await response.json();
console.log(`✅ Loaded: ${post.title}`);
} catch (error) {
console.log(`❌ Error: ${error.message}`);
} finally {
console.log("🏁 Loading complete — cleanup done.");
}
}
loadData();
finally runs no matter what — perfect for hiding loading spinners, closing connections, or any cleanup.
Handling Multiple Awaits
Each await in a try block is covered by the same catch. If step 2 fails, you don't need a separate error handler:
async function processOrder() {
try {
const user = await getUser(1);
const orders = await getOrders(user.id); // If THIS fails...
const shipping = await getShipping(orders[0].id);
console.log(`Shipping to: ${shipping.address}`);
} catch (error) {
console.log(`Something failed: ${error.message}`); // ...it's caught HERE
}
}
Compare this to the Promise version where .catch() at the end catches errors from any step — same behavior, but try/catch reads more naturally.
Promise vs Async/Await — Side by Side
Sequential Operations
// Promise chain
function getProfile() {
return getUser(1)
.then((user) => {
return getAvatar(user.avatarId);
})
.then((avatar) => {
console.log(`Avatar URL: ${avatar.url}`);
})
.catch((err) => {
console.log(err);
});
}
// Async/await
async function getProfile() {
try {
const user = await getUser(1);
const avatar = await getAvatar(user.avatarId);
console.log(`Avatar URL: ${avatar.url}`);
} catch (err) {
console.log(err);
}
}
Error Handling
// Promise
fetchData()
.then((data) => processData(data))
.then((result) => saveResult(result))
.catch((err) => console.log("Error:", err));
// Async/await
async function handleData() {
try {
const data = await fetchData();
const result = await processData(data);
await saveResult(result);
} catch (err) {
console.log("Error:", err);
}
}
Comparison Table
| Feature | Promises (.then()) |
Async/Await |
|---|---|---|
| Syntax |
.then() / .catch() chains |
await + try/catch
|
| Readability | Good — but chains can get long | Excellent — reads like sync code |
| Error handling |
.catch() at the end |
try/catch — familiar syntax |
| Debugging | Harder — stack traces less clear | Easier — breakpoints work normally |
| Line-by-line | Need to follow the chain | Each await is its own line |
| Under the hood | Promises | Also Promises (syntactic sugar) |
| When to use | Simple chains, .all(), .race()
|
Sequential async, most use cases |
Promise vs Async/Await Flow
PROMISE CHAIN:
getUser(1) ──→ .then() ──→ .then() ──→ .then() ──→ .catch()
│ │ │ │
returns a returns a returns a catches any
Promise Promise Promise rejection
ASYNC/AWAIT:
async function() {
const user = await getUser(1); ← pause, get value
const orders = await getOrders(); ← pause, get value
const ship = await getShipping(); ← pause, get value
console.log(ship.address); ← use the value
}
Same thing. Different syntax. Async/await just reads top-to-bottom.
Practical Patterns
Pattern 1: Fetching Data from a Real API
async function displayUser() {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users/1",
);
const user = await response.json();
console.log(`Name: ${user.name}`);
console.log(`Email: ${user.email}`);
console.log(`City: ${user.address.city}`);
} catch (error) {
console.log("Failed to fetch user:", error.message);
}
}
displayUser();
Pattern 2: Sequential Operations
async function morningRoutine() {
const coffee = await makeCoffee(); // Wait for coffee first
const breakfast = await cookBreakfast(); // Then cook breakfast
const news = await fetchNews(); // Then fetch news
console.log(`Enjoying ${coffee} with ${breakfast} while reading ${news}`);
}
Each step waits for the previous one. Order matters here.
Pattern 3: Parallel Operations with Promise.all()
Sometimes steps are independent — they don't depend on each other. Running them sequentially wastes time:
// ❌ Sequential — slow (3 seconds total)
async function loadDashboard() {
const user = await fetchUser(); // 1 second
const posts = await fetchPosts(); // 1 second
const notifications = await fetchNotifications(); // 1 second
// Total: 3 seconds 😴
}
// ✅ Parallel — fast (1 second total)
async function loadDashboard() {
const [user, posts, notifications] = await Promise.all([
fetchUser(), // 1 second ─┐
fetchPosts(), // 1 second ├─ all run simultaneously
fetchNotifications(), // 1 second ─┘
]);
// Total: ~1 second 🚀
}
Promise.all() runs all three fetches at the same time and waits for all of them to finish. If any one fails, the entire Promise.all() rejects.
Pattern 4: Async in Arrow Functions
const getUser = async (id) => {
const response = await fetch(`https://api.example.com/users/${id}`);
return response.json();
};
const user = await getUser(1);
Let's Practice: Hands-On Assignment
Part 1: Basic Async/Await
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function countdown() {
console.log("3...");
await delay(1000);
console.log("2...");
await delay(1000);
console.log("1...");
await delay(1000);
console.log("🚀 Go!");
}
countdown();
Part 2: Fetch Real Data
async function getPost() {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/posts/1",
);
const post = await response.json();
console.log(`Title: ${post.title}`);
console.log(`Body: ${post.body}`);
} catch (error) {
console.log("Error:", error.message);
}
}
getPost();
Part 3: Sequential vs Parallel
const fakeAPI = (name, ms) =>
new Promise((resolve) => {
setTimeout(() => resolve(`${name} loaded`), ms);
});
// Sequential — runs one after another
async function sequential() {
console.time("Sequential");
const a = await fakeAPI("Users", 1000);
const b = await fakeAPI("Posts", 1000);
const c = await fakeAPI("Comments", 1000);
console.log(a, b, c);
console.timeEnd("Sequential"); // ~3 seconds
}
// Parallel — runs all at once
async function parallel() {
console.time("Parallel");
const [a, b, c] = await Promise.all([
fakeAPI("Users", 1000),
fakeAPI("Posts", 1000),
fakeAPI("Comments", 1000),
]);
console.log(a, b, c);
console.timeEnd("Parallel"); // ~1 second
}
sequential();
// parallel(); // Try this one too!
Part 4: Error Handling
async function riskyFetch() {
try {
const response = await fetch("https://invalid-url.example.com");
const data = await response.json();
console.log("Data:", data);
} catch (error) {
console.log("Caught an error:", error.message);
} finally {
console.log("Fetch attempt complete.");
}
}
riskyFetch();
Key Takeaways
- Async/await is syntactic sugar over Promises. It doesn't replace them — it makes them easier to write and read.
-
asyncmakes a function return a Promise.awaitpauses the function until a Promise resolves, then gives you the value. -
awaitonly pauses the async function, not the entire program. Other code continues running while the function waits. -
Error handling uses
try/catch— the same familiar syntax from synchronous JavaScript. Addfinallyfor cleanup. - Use
Promise.all()for independent operations that can run in parallel. Use sequentialawaitwhen each step depends on the previous one.
Wrapping Up
Async/await is the final piece of the async JavaScript puzzle. Callbacks showed us the concept. Promises gave us structure. Async/await gave us readability. The fact that you can write asynchronous code that looks and reads like regular, top-to-bottom JavaScript — with proper error handling and no nesting — is genuinely one of the best things about modern JavaScript.
I'm learning all of this through the ChaiCode Web Dev Cohort 2026 under Hitesh Chaudhary and Piyush Garg. The journey from callbacks → Promises → async/await has been one of the most satisfying progressions in the cohort. Each one builds on the last, and by the time you reach async/await, it all just clicks.
Connect with me on LinkedIn or visit PrathamDEV.in. More articles coming as the journey continues.
Happy coding! 🚀
Written by Pratham Bhardwaj | Web Dev Cohort 2026, ChaiCode
Top comments (0)