AI News Hub Logo

AI News Hub

Async/Await in JavaScript: Writing Cleaner Asynchronous Code

DEV Community
Pratham

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. 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. 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. 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 await keyword inside it 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"; }; async function example() { return 42; } const result = example(); console.log(result); // Promise { : 42 } console.log(result instanceof Promise); // true It's a Promise. Always. Even if you return a plain value, it gets wrapped. 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. await async function getUser() { const response = fetch("https://jsonplaceholder.typicode.com/users/1"); console.log(response); // Promise { } — not the data! } 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." await await can only be used inside async functions (or at the top level of a module) await pauses the async function, not the entire program — other code keeps running await works with any Promise — built-in ones like fetch() 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. 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. try/catch With Promises, you use .catch(). With async/await, you use try/catch — the same error handling syntax you use for synchronous code. 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. 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. 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 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); } } // 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); } } 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 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. 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(); 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. 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. const getUser = async (id) => { const response = await fetch(`https://api.example.com/users/${id}`); return response.json(); }; const user = await getUser(1); 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(); 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(); 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! 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(); Async/await is syntactic sugar over Promises. It doesn't replace them — it makes them easier to write and read. async makes a function return a Promise. await pauses the function until a Promise resolves, then gives you the value. await only 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. Add finally for cleanup. Use Promise.all() for independent operations that can run in parallel. Use sequential await when each step depends on the previous one. 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