Async/Await Architecture
Reviewed & published by Brayan K
async/await is JavaScript syntax that lets you write asynchronous, Promise-based code in a clean, sequential style, pausing on await until a Promise settles instead of chaining callbacks.
Part of the free JavaScript course at LearnCodingFast β hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Async/await mechanics & internals
- Sequential vs Parallel execution patterns
- Error handling architecture
- Scalable async pipelines
- Concurrency control & throttling
- Safe timeout-bound operations
π‘ Running Code Locally: While this online editor runs real JavaScript, some advanced examples (like fetch to external APIs) may have limitations. For the best experience:
- Download Node.js to run JavaScript on your computer
- Use your browser's Developer Console (Press F12) to test code snippets
- Create a .html file with <script> tags and open it in your browser
One thing to know for this lesson in particular: the built-in editor grabs your output at the moment your code stops running, and async results by definition arrive after that. So a snippet whose only output comes from a setTimeout or a .then() can look like it printed nothing here. Save it as demo.js and run node demo.js, or paste it into DevTools (F12), and you will see every line. The expected outputs written in the examples below are exactly what those two show.
Async/await is more than just a cleaner way to write Promises β it is the foundation of modern JavaScript architecture. Every large-scale platform in 2025 uses async/await as the core mechanism for handling network calls, database operations, streaming data, and server requests.
π₯ Why Async/Await Dominates Modern Development
Async/await is used everywhere:
- Next.js server actions
- React hooks and data fetching
- Node.js APIs, workers, and modules
- Cloud functions (Firebase, AWS Lambda, Google Cloud Workers)
- AI inference pipelines (LLMs, embeddings, batching)
- Mobile apps (React Native + APIs)
// Simulated async function
async function getUserProfile(id) {
// Simulating API calls
const user = await Promise.resolve({ id, name: "John" });
const stats = await Promise.resolve({ posts: 42, followers: 1000 });
return { ...user, stats };
}
getUserProfile(1).then(console.log);π₯ How Async Functions Actually Work Internally
When you write an async function, the engine transforms it into a Promise:
// This:
async function example() {
return 42;
}
// Becomes this internally:
function example2() {
return Promise.resolve(42);
}
example().then(console.log);
example2().then(console.log);π₯ Sequential vs Parallel β The Biggest Beginner Mistake
// Simulated async tasks
const fetchA = () => new Promise(r => setTimeout(() => r("A"), 100));
const fetchB = () => new Promise(r => setTimeout(() => r("B"), 100));
// β WRONG - Sequential (200ms total)
async function sequential() {
console.time("sequential");
const a = await fetchA();
const b = await fetchB();
console.timeEnd("sequential");
console.log(a, b);
}
// β
CORRECT - Parallel (100ms total)
async function parallel() {
console.time("parallel");
const [a, b] = await Promise.all([fetchA(), fetchB()]);
console.timeEnd("parallel");
console.log(a, b);
}
sequential().then(() => parallel());π Worked example β watch the difference happen
Stopwatch numbers wobble from run to run, so this version prints a line when each fake API call starts and another when it finishes. Read those lines in order and the whole idea becomes visible: in the sequential run every call waits its turn, while in the parallel run all three start on the same tick. Give each call a different duration and one more thing shows up too β Promise.all hands results back in the order you listed them, not the order they finished in.
// WORKED EXAMPLE - the one async decision that actually changes speed:
// do these tasks depend on each other, or not?
// A fake API call. It announces when it starts and when it finishes,
// so you can SEE the difference instead of guessing at millisecond counts.
function fakeFetch(label, ms) {
console.log(" start " + label);
return new Promise(function (resolve) {
setTimeout(function () {
console.log(" done " + label);
resolve(label.toUpperCase()); // the "data" this call returns
}, ms);
});
}
// ---------- Sequential: each await waits for the previous one ----------
async function sequential() {
console.log("SEQUENTIAL (one after another)");
const profile = await fakeFetch("profile", 60); // nothing else runs until this finishes
const orders = await fakeFetch("orders", 120); // only NOW does this one start
const offers = await fakeFetch("offers", 30);
console.log(" got: " + profile + " " + orders + " " + offers);
}
// ---------- Parallel: start all three, then wait for all three ----------
async function parallel() {
console.log("PARALLEL (all at once)");
const [profile, orders, offers] = await Promise.all([
fakeFetch("profile", 60), // calling the function STARTS the work
fakeFetch("orders", 120), // so all three timers are already running
fakeFetch("offers", 30)
]);
// Promise.all gives results back in ARRAY order, not finishing order:
console.log(" got: " + profile + " " + orders + " " + offers);
}
// Run one, then the other, so the two sets of logs do not interleave.
sequential().then(parallel);
// β
Expected output (node demo.js, or your browser console):
// SEQUENTIAL (one after another)
// start profile
// done profile
// start orders
// done orders
// start offers
// done offers
// got: PROFILE ORDERS OFFERS
// PARALLEL (all at once)
// start profile
// start orders
// start offers
// done offers
// done profile
// done orders
// got: PROFILE ORDERS OFFERS
//
// Read the PARALLEL half twice: offers finishes FIRST (it is the 30ms one),
// yet it is still third in the results β Promise.all keeps the order you
// passed in, not the order they finished.π― Your Turn β make three calls run at once
A blog post page needs its title, its author and its like count. None of them needs the others, so waiting for them one at a time is wasted time. Three blanks are marked with ___; everything else is written for you.
// π― YOUR TURN - fill in the three blanks marked ___
// Three independent fake API calls (already written for you):
const fetchTitle = () => new Promise(r => setTimeout(() => r("Async in 10 Minutes"), 80));
const fetchAuthor = () => new Promise(r => setTimeout(() => r("R. Hopper"), 40));
const fetchLikes = () => new Promise(r => setTimeout(() => r(1284), 120));
async function loadPost() {
// The three results are unpacked in the same order you list the calls.
const [title, author, likes] = await Promise.___([ // π replace ___ with all
fetchTitle(),
fetchAuthor(),
___ // π add the third call: fetchLikes()
]);
console.log(title + " by " + author + " - " + ___ + " likes"); // π replace ___ with likes
}
loadPost();
// β
Expected output once the blanks are filled:
// Async in 10 Minutes by R. Hopper - 1284 likes
//
// Hint: if you get "Promise.___ is not a function", you have not replaced blank 1.
// If you get "undefined likes", check blank 3 - it wants the variable, not a string.π₯ Real-World Parallel Architecture
Imagine a backend for an app like YouTube:
// Simulated data fetchers
const getUser = id => Promise.resolve({ id, name: "User" });
const getSubscriptions = id => Promise.resolve(["Channel1", "Channel2"]);
const getRecommendations = id => Promise.resolve(["Video1", "Video2"]);
const getNotifications = id => Promise.resolve([{ msg: "New video" }]);
const getHistory = id => Promise.resolve(["Watched1", "Watched2"]);
// β
FAST - All requests in parallel
async function loadDashboard(id) {
const [user, subs, recs, notes, history] = await Promise.all([
getUser(id),
getSubscriptions(id),
getRecommendations(id),
getNotifications(id),
getHistory(id)
]);
return { user, subs, recs, notes, history };
}
loadDashboard(1).then(data => console.log("Dashboard:", data));π₯ Error Handling β Professional Pattern
// Professional error handling pattern
const wrap = async (promise) => {
try {
return [await promise, null];
} catch (e) {
return [null, e];
}
};
// Usage
async function fetchData() {
// Simulate sometimes failing
if (Math.random() > 0.5) throw new Error("Network error");
return { data: "Success!" };
}
async function main() {
const [data, err] = await wrap(fetchData());
if (err) {
console.log("Error handled:", err.message);
return { fallback: true };
}
console.log("Data received:", data);
}
main();π₯ Avoiding the "Zombie Await" Anti-Pattern
const asyncTask = () => Promise.resolve("done");
// β BAD - Sequential zombie awaits
async function bad() {
console.time("bad");
await asyncTask();
await asyncTask();
await asyncTask();
console.timeEnd("bad");
}
// β
GOOD - Run in parallel
async function good() {
console.time("good");
await Promise.all([
asyncTask(),
asyncTask(),
asyncTask()
]);
console.timeEnd("good");
}
bad().then(() => good());π₯ Designing Async Functions for Scalability
// β Bad - depends on global state
let counter = 0;
async function badGetUser(id) {
counter++;
return { id, count: counter };
}
// β
Good - pure function, pass dependencies
async function goodGetUser(db, id) {
// db would be passed in
return { id, data: "from db" };
}
// Test - await only works INSIDE an async function, so wrap the calls in one
async function test() {
console.log("Bad (impure):", await badGetUser(1)); // { id: 1, count: 1 }
console.log("Bad (impure):", await badGetUser(1)); // { id: 1, count: 2 } - same input, different answer!
console.log("Good (pure):", await goodGetUser({}, 1)); // { id: 1, data: 'from db' } every time
}
test();π₯ The "Return Early" Pattern
async function purchase(user, item) {
// Return early on validation failure
if (user.balance < item.price) {
return { ok: false, reason: "insufficient funds" };
}
// Simulate charging
const receipt = await Promise.resolve({
id: Date.now(),
item: item.name
});
return { ok: true, receipt };
}
// Test cases
purchase({ balance: 10 }, { name: "Shirt", price: 25 })
.then(r => console.log("Low balance:", r));
purchase({ balance: 100 }, { name: "Shirt", price: 25 })
.then(r => console.log("Success:", r));β‘ The Hidden Architecture of Async/Await
async function demo() {
console.log("A");
await Promise.resolve();
console.log("B");
}
demo();
console.log("C");
// Output order explained:
// A - runs synchronously
// C - runs while awaiting
// B - runs after microtaskπ Error Surfacing: Before vs After Await
// Errors before await = sync rejection
async function beforeAwait() {
throw new Error("sync fail");
await Promise.resolve();
}
// Errors after await = async rejection
async function afterAwait() {
await Promise.resolve();
throw new Error("async fail");
}
// Safe fetch pattern
async function safeFetch(shouldFail) {
try {
if (shouldFail) throw new Error("Network error");
return { data: "success" };
} catch (err) {
console.log("Caught:", err.message);
return null; // graceful fallback
}
}
safeFetch(true);
safeFetch(false).then(console.log);ποΈ Architecting Async Pipelines
// Real-world async pipeline
async function processVideo(video) {
console.log("Processing:", video);
const raw = await Promise.resolve({ frames: 100 });
// Parallel processing
const [compressed, metadata] = await Promise.all([
Promise.resolve({ size: "10MB" }),
Promise.resolve({ duration: "5min" })
]);
const thumbnail = await Promise.resolve({ url: "/thumb.jpg" });
return {
thumbnail,
compressed,
metadata
};
}
processVideo("video.mp4").then(console.log);β‘ Safe Timeout-Bound Operations
async function withTimeout(ms, promise) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout exceeded")), ms)
);
return Promise.race([promise, timeout]);
}
// Fast operation - succeeds
const fast = new Promise(r => setTimeout(() => r("Fast!"), 50));
withTimeout(100, fast)
.then(r => console.log("Fast result:", r))
.catch(e => console.log("Fast error:", e.message));
// Slow operation - times out
const slow = new Promise(r => setTimeout(() => r("Slow!"), 200));
withTimeout(100, slow)
.then(r => console.log("Slow result:", r))
.catch(e => console.log("Slow error:", e.message));π― Mini-Challenge β Load an Account Page
Real pages are a mix: some data is independent, some data can only be fetched once you know the answer to an earlier call. This is the judgement call the whole lesson has been building towards, so this time there is no code to fill in β only an outline.
- getUser() and getSettings() do not need each other, so they should start together.
- getPlan(planId) needs user.planId, so it cannot start until the user has arrived.
- One try/catch around the lot, at the boundary.
// π― MINI-CHALLENGE: load an account page the right way
//
// The three fake API calls are written for you. You write loadAccountPage().
const getUser = () => new Promise(r => setTimeout(() => r({ name: "Ada", planId: "pro" }), 60));
const getSettings = () => new Promise(r => setTimeout(() => r({ theme: "dark" }), 40));
const getPlan = (planId) => new Promise(r => setTimeout(() => r({ name: planId.toUpperCase(), price: 12 }), 50));
async function loadAccountPage() {
// 1. Start getUser() and getSettings() together with Promise.all and
// destructure the two results into user and settings.
// 2. THEN await getPlan(user.planId) - it depends on step 1, so it cannot
// go inside the same Promise.all.
// 3. Print exactly three lines:
// "user: " + user.name
// "theme: " + settings.theme
// "plan: " + plan.name + " (" + plan.price + "/month)"
// 4. Wrap steps 1-3 in one try/catch. In the catch, print
// "failed: " + err.message
//
// your code here
}
loadAccountPage();
// β
Expected output:
// user: Ada
// theme: dark
// plan: PRO (12/month)
//
// Check yourself: if you try to squeeze getPlan(user.planId) into the same
// Promise.all, you get "failed: Cannot access 'user' before initialization" -
// the engine telling you that call needs data which has not arrived yet.π― Key Takeaways
- β Async/await is the foundation of modern JavaScript architecture
- β Always use Promise.all for parallel operations
- β Professional error handling patterns prevent silent failures
- β Distinguish between CPU-bound and I/O-bound tasks
- β Design pure, scalable async functions
- β Use concurrency control for batch operations
- β Always implement timeouts for external services
When all these advanced techniques come togetherβstructured pipelines, concurrency control, safe resource handling, clear traces, timeouts, and robust error architectureβyou reach a level where async/await becomes a powerful tool rather than a confusing abstraction.
Practice quiz
An async function always returns what?
- The raw value
- undefined
- A Promise
- A generator
Answer: A Promise. async function example() { return 42; } is equivalent to returning Promise.resolve(42).
What does 'await' do inside an async function?
- Pauses that function until the promise settles, without blocking the thread
- Blocks the whole thread until the promise settles
- Cancels the promise
- Converts the promise to a callback
Answer: Pauses that function until the promise settles, without blocking the thread. await pauses the async function until the awaited promise settles, while the rest of the program keeps running.
Two independent 100ms tasks run with: await fetchA(); await fetchB(); How long does that take?
- About 100ms
- About 50ms
- It runs instantly
- About 200ms
Answer: About 200ms. Awaiting them one after another is sequential, so the times add up to roughly 200ms.
How do you run those two independent tasks in parallel (about 100ms total)?
- await fetchA(); await fetchB();
- const [a, b] = await Promise.all([fetchA(), fetchB()]);
- Promise.race([fetchA(), fetchB()])
- Run them in a for loop
Answer: const [a, b] = await Promise.all([fetchA(), fetchB()]);. Promise.all starts both at once and waits for both, so the total is about one task's duration.
What does this log in order: console.log('A'); await Promise.resolve(); console.log('B'); ... demo(); console.log('C');
- A C B
- A B C
- C A B
- A B then C
Answer: A C B. A runs synchronously, C runs while awaiting, and B runs after as a microtask: A, C, B.
In the tuple error-handling pattern, what does wrap(promise) resolve to on success?
- [null, error]
- Just data
- [data, null]
- A thrown error
Answer: [data, null]. wrap returns [await promise, null] on success and [null, e] on failure, so you destructure [data, err].
What is the 'zombie await' anti-pattern?
- Forgetting to await a promise
- Awaiting independent tasks sequentially when they could run in parallel
- Using await outside async
- Catching errors twice
Answer: Awaiting independent tasks sequentially when they could run in parallel. Sequentially awaiting independent operations wastes time; running them with Promise.all is faster.
What does the withTimeout(ms, promise) helper use to enforce the deadline?
- Promise.all
- setInterval
- AbortController only
- Promise.race against a timer that rejects
Answer: Promise.race against a timer that rejects. It races the real promise against a setTimeout-based promise that rejects, so the slower one loses.
An error thrown after an await in an async function surfaces as...
- A synchronous throw
- A rejected promise
- A silent failure
- A console warning
Answer: A rejected promise. Once execution has passed an await, a throw rejects the function's returned promise rather than throwing synchronously.
Why design async functions to be pure and take dependencies as parameters?
- It makes them run faster
- It is required by the syntax
- It makes them scalable and testable rather than relying on global state
- It avoids using await
Answer: It makes them scalable and testable rather than relying on global state. Passing dependencies in (instead of mutating globals) keeps async functions predictable and easy to scale and test.
Continue this course
- Previous: Mastering Promises: Chaining, Error Flow & Patterns
- Next: JavaScript Modules: ES6 Imports, Exports, Bundling β Split code into modules and understand how bundlers resolve imports
- Quick reference: JavaScript cheat sheet