async and await in JavaScript — How, Why, and When to Use Them
Every real app talks to something slow — a database, an API, a file system. If JavaScript stopped and stared at the wall waiting for each of those operations to finish, your UI would freeze, your server would stall, and your users would leave. Asynchronous programming is the reason JavaScript can handle thousands of requests without breaking a sweat, and async/await is the cleanest way to write that kind of code today.
Before async/await landed in ES2017, developers handled async operations with callbacks, then Promises. Both work, but callbacks turn into a pyramid of doom and raw Promise chains get noisy fast. async and await don't replace Promises — they're syntactic sugar that sits on top of them, letting you write async code that reads like normal top-to-bottom synchronous code, which is how your brain naturally thinks.
By the end of this article you'll understand exactly why async/await exists, how it maps to the Promise model underneath, how to handle errors properly, and how to avoid the performance trap that catches most intermediate developers — running async operations in sequence when they could run in parallel. You'll write better, faster, more readable async code starting today.
What async and await Actually Do Under the Hood
The async keyword does one thing: it makes a function always return a Promise. That's it. If you return a plain value like 42, JavaScript quietly wraps it in Promise.resolve(42). This matters because it means every async function plugs seamlessly into the existing Promise ecosystem — you can chain .then() on an async function's return value if you ever need to.
The await keyword can only live inside an async function. It pauses that function's execution, hands control back to the event loop so other code can run, then resumes the function once the Promise it's waiting on settles. Critically, await does NOT block the thread — it blocks only that specific function's execution while the rest of your program continues running.
Think of async functions as generators with built-in Promise plumbing. The JavaScript engine essentially transforms your await expressions into .then() callbacks at compile time. This is why understanding Promises first makes async/await click immediately — you're not learning something new, you're learning a cleaner way to write what you already know.
This mental model prevents a lot of confusion. When something goes wrong with async/await, the debugging answer almost always lives in understanding the underlying Promise behavior.
// ─── Demonstrating that async functions always return a Promise ─── async function getWelcomeMessage() { // We return a plain string, but the async keyword // automatically wraps this in Promise.resolve('Hello!') return 'Hello from an async function!'; } // Proof: call it and check what we get back const result = getWelcomeMessage(); console.log(result instanceof Promise); // true — it's always a Promise // Use .then() on it — totally valid because it IS a Promise result.then((message) => console.log(message)); // ─── Demonstrating what await actually does ─── function simulateNetworkDelay(ms) { // Returns a Promise that resolves after `ms` milliseconds // This mimics a real API call or database query return new Promise((resolve) => setTimeout(resolve, ms)); } async function fetchUserProfile() { console.log('1. fetchUserProfile started'); // await pauses THIS function but NOT the whole program. // The event loop is free to run other code during this wait. await simulateNetworkDelay(1000); console.log('3. fetchUserProfile resumed after 1 second'); return { id: 42, name: 'Sarah Connor', role: 'engineer' }; } console.log('0. Before calling fetchUserProfile'); fetchUserProfile().then((user) => console.log('4. Got user:', user.name)); console.log('2. After calling fetchUserProfile — proof the thread is NOT blocked');
Hello from an async function!
0. Before calling fetchUserProfile
1. fetchUserProfile started
2. After calling fetchUserProfile — proof the thread is NOT blocked
(... 1 second passes ...)
3. fetchUserProfile resumed after 1 second
4. Got user: Sarah Connor
Error Handling in async/await — Do This, Not That
This is where most developers take a wrong turn. A rejected Promise inside an async function that has no error handling will give you an UnhandledPromiseRejection warning and silently swallow the error in some environments. You need a strategy — and the right strategy depends on your context.
The most readable pattern is try/catch, which lets you handle errors exactly the way you'd handle synchronous errors. This is one of the biggest wins of async/await — unified error handling between sync and async code in a single try/catch block.
But try/catch has a trap: if you need to differentiate between different kinds of failures in one block, it gets messy. In those cases, a small helper pattern — sometimes called a 'safe await' or 'to' pattern — lets you handle errors inline without a try/catch tower.
The golden rule: never use async/await without an error handling strategy. An async function that can fail and has no .catch() or try/catch is a time bomb. In production Node.js apps, unhandled Promise rejections can crash the process entirely since Node 15.
// ─── Pattern 1: try/catch — most readable for most situations ─── async function loadUserDashboard(userId) { try { // Both of these can throw — one try/catch covers both const userResponse = await fetch(`https://api.example.com/users/${userId}`); if (!userResponse.ok) { // Manually throw so we catch HTTP errors, not just network errors throw new Error(`HTTP ${userResponse.status}: Failed to load user ${userId}`); } const user = await userResponse.json(); // This can also throw if JSON is malformed console.log(`Dashboard loaded for: ${user.name}`); return user; } catch (error) { // One place to handle every failure in this async flow console.error('Dashboard failed to load:', error.message); return null; // Return a safe fallback so callers don't blow up } } // ─── Pattern 2: The 'safeAwait' helper — great for granular error handling ─── // Instead of nested try/catches, wrap each await to get [error, result] tuples // Inspired by Go's error handling style async function safeAwait(promise) { try { const data = await promise; return [null, data]; // [error, result] — error is null on success } catch (error) { return [error, null]; // error is populated on failure } } async function processOrder(orderId) { // Step 1: Validate the order const [validationError, order] = await safeAwait( fetch(`https://api.example.com/orders/${orderId}`).then((r) => r.json()) ); if (validationError) { console.error('Order validation failed:', validationError.message); return; } // Step 2: Charge the customer — different error, different handling const [paymentError, receipt] = await safeAwait( fetch('https://api.example.com/payments', { method: 'POST', body: JSON.stringify({ orderId: order.id, amount: order.total }), }).then((r) => r.json()) ); if (paymentError) { // Handle payment failure specifically — maybe alert the user differently console.error('Payment failed — order preserved for retry:', paymentError.message); return; } console.log(`Order ${orderId} confirmed. Receipt ID: ${receipt.id}`); } // ─── Pattern 3: Propagate errors upward intentionally ─── // Sometimes you WANT the error to bubble up to the caller async function getProductPrice(productId) { const response = await fetch(`https://api.example.com/products/${productId}`); if (!response.ok) throw new Error(`Product ${productId} not found`); const product = await response.json(); return product.price; // No try/catch here — the caller decides how to handle the failure } // The caller owns the error strategy async function renderProductPage(productId) { try { const price = await getProductPrice(productId); console.log(`Price: $${price}`); } catch (error) { console.log('Could not load price. Showing "Contact for pricing" instead.'); } }
Dashboard loaded for: Sarah Connor
// Pattern 1 failure path:
Dashboard failed to load: HTTP 404: Failed to load user 999
// Pattern 2 payment failure:
Payment failed — order preserved for retry: Card declined
// Pattern 3 fallback:
Could not load price. Showing "Contact for pricing" instead.
Sequential vs Parallel Execution — The Performance Trap
This is the mistake that separates developers who understand async from those who just use it. When you write multiple await statements one after another, they run sequentially — each one waits for the previous to finish before starting. For operations that are independent of each other, this is a massive and completely unnecessary performance hit.
Imagine you need a user's profile, their recent orders, and their notification count to render a dashboard. None of those three requests depends on the others. Running them sequentially means if each takes 300ms, your dashboard takes 900ms. Running them in parallel means it takes 300ms — the time of the slowest one.
Promise.all() is your tool here. It fires all Promises at once and waits for all of them to resolve, giving you a big performance win for independent operations. Use Promise.allSettled() when you want all results regardless of whether some fail — it never rejects, it gives you an array of outcome objects.
Promise.race() resolves or rejects as soon as the first Promise settles — great for implementing timeouts. Promise.any() resolves with the first success and only rejects if all fail — useful for redundant data sources like CDN fallbacks.
// ─── The slow way: Sequential (bad for independent operations) ─── async function loadDashboardSlowly(userId) { console.time('sequential'); // Each await WAITS for the previous one to finish. // Total time = 400ms + 350ms + 300ms = 1050ms const userProfile = await fetchUserProfile(userId); // 400ms const recentOrders = await fetchRecentOrders(userId); // 350ms const notifications = await fetchNotifications(userId); // 300ms console.timeEnd('sequential'); // ~1050ms return { userProfile, recentOrders, notifications }; } // ─── The fast way: Parallel with Promise.all() ─── async function loadDashboardFast(userId) { console.time('parallel'); // All three fetches START at the same time. // Total time ≈ max(400ms, 350ms, 300ms) = 400ms const [userProfile, recentOrders, notifications] = await Promise.all([ fetchUserProfile(userId), // Fires immediately fetchRecentOrders(userId), // Also fires immediately fetchNotifications(userId), // Also fires immediately ]); // Note: if ANY of these rejects, the whole Promise.all rejects. // Use Promise.allSettled if you want partial results on failure. console.timeEnd('parallel'); // ~400ms return { userProfile, recentOrders, notifications }; } // ─── When some requests CAN fail without breaking everything ─── async function loadDashboardResilient(userId) { const results = await Promise.allSettled([ fetchUserProfile(userId), fetchRecentOrders(userId), fetchNotifications(userId), ]); // allSettled never throws — each result has a status const [profileResult, ordersResult, notificationsResult] = results; const dashboard = { // Show user profile if loaded, otherwise show a fallback user: profileResult.status === 'fulfilled' ? profileResult.value : { name: 'Guest', role: 'unknown' }, // Show orders if loaded, otherwise show empty state orders: ordersResult.status === 'fulfilled' ? ordersResult.value : [], // Notification count can silently fail — not critical notificationCount: notificationsResult.status === 'fulfilled' ? notificationsResult.value.length : 0, }; if (ordersResult.status === 'rejected') { console.warn('Orders failed to load:', ordersResult.reason.message); } return dashboard; } // ─── Mock fetch helpers to make this runnable ─── function fetchUserProfile(userId) { return new Promise((resolve) => setTimeout(() => resolve({ id: userId, name: 'Alex Rivera', role: 'admin' }), 400) ); } function fetchRecentOrders(userId) { return new Promise((resolve) => setTimeout(() => resolve([{ id: 'ORD-001', total: 89.99 }]), 350) ); } function fetchNotifications(userId) { return new Promise((resolve) => setTimeout(() => resolve([{ msg: 'Your order shipped' }, { msg: 'Flash sale today' }]), 300) ); } // Run the fast version loadDashboardFast(7).then((data) => { console.log('User:', data.userProfile.name); console.log('Orders:', data.recentOrders.length); console.log('Notifications:', data.notifications.length); });
User: Alex Rivera
Orders: 1
Notifications: 2
Real-World Pattern — Building a Resilient API Service
Knowing the mechanics is one thing. Knowing how to structure async code in a real application is another. Most production JavaScript — whether Node.js servers or React apps — wraps its async logic in service layers: plain modules that own all the fetch/database logic and export clean async functions.
This pattern keeps your components or route handlers lean. They just call await userService.getProfile(id) and handle the result. The async complexity is encapsulated in one place.
Let's also add a timeout pattern, because every real API call needs one. You can't let a slow third-party API hang your server forever. Promise.race() between your actual request and a timeout Promise is the classic solution.
Finally, adding a retry mechanism for transient failures is something every production app needs. A request that fails once due to a brief network hiccup should retry before giving up. These three ideas — service layer, timeout, and retry — are the building blocks of resilient async code.
// ─── A production-quality async service with timeout and retry ─── // Utility: Rejects after `ms` milliseconds — used for timeouts function createTimeoutPromise(ms, operationName) { return new Promise((_, reject) => setTimeout( () => reject(new Error(`${operationName} timed out after ${ms}ms`)), ms ) ); } // Utility: Retries a Promise-returning function up to `maxAttempts` times async function withRetry(asyncFn, maxAttempts = 3, delayMs = 500) { let lastError; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await asyncFn(); // Try running the function } catch (error) { lastError = error; const isLastAttempt = attempt === maxAttempts; if (isLastAttempt) break; console.warn(`Attempt ${attempt} failed. Retrying in ${delayMs}ms...`); // Wait before retrying — prevents hammering a struggling server await new Promise((resolve) => setTimeout(resolve, delayMs)); } } throw lastError; // All retries exhausted — propagate the final error } // ─── The actual service layer ─── const WEATHER_API_TIMEOUT_MS = 5000; async function fetchWeatherData(cityName) { // Race: the real request vs a timeout — whoever settles first wins const weatherPromise = fetch( `https://api.openweathermap.org/data/2.5/weather?q=${cityName}&appid=DEMO_KEY` ).then((response) => { if (!response.ok) { throw new Error(`Weather API error: ${response.status} for city '${cityName}'`); } return response.json(); }); return Promise.race([ weatherPromise, createTimeoutPromise(WEATHER_API_TIMEOUT_MS, `fetchWeatherData(${cityName})`), ]); } // Public API of our weather service — this is what callers use const weatherService = { async getWeather(cityName) { // Wrap the fetch in our retry utility — handles transient failures const rawData = await withRetry( () => fetchWeatherData(cityName), 3, // up to 3 attempts 800 // 800ms between retries ); // Transform the raw API response into a clean shape our app cares about return { city: rawData.name, country: rawData.sys.country, temperatureCelsius: Math.round(rawData.main.temp - 273.15), description: rawData.weather[0].description, humidity: rawData.main.humidity, fetchedAt: new Date().toISOString(), }; }, async getWeatherForMultipleCities(cityNames) { // Fetch all cities in parallel — don't make users wait for each one const weatherResults = await Promise.allSettled( cityNames.map((city) => this.getWeather(city)) ); return weatherResults.map((result, index) => ({ city: cityNames[index], success: result.status === 'fulfilled', data: result.status === 'fulfilled' ? result.value : null, error: result.status === 'rejected' ? result.reason.message : null, })); }, }; // ─── Usage — clean and readable because the complexity is hidden ─── async function renderWeatherDashboard() { const cities = ['London', 'Tokyo', 'New York']; console.log(`Fetching weather for: ${cities.join(', ')}`); const results = await weatherService.getWeatherForMultipleCities(cities); results.forEach(({ city, success, data, error }) => { if (success) { console.log(`${city}: ${data.temperatureCelsius}°C, ${data.description}`); } else { console.log(`${city}: Failed to load — ${error}`); } }); } renderWeatherDashboard();
London: 14°C, light rain
Tokyo: 22°C, clear sky
New York: Failed to load — Weather API error: 401 for city 'New York'
| Feature / Aspect | Promise .then()/.catch() | async / await |
|---|---|---|
| Readability | Chains get noisy with multiple steps | Reads like synchronous code — linear and clean |
| Error handling | .catch() at the end of a chain | try/catch blocks — same syntax as sync errors |
| Debugging stack traces | Stack traces often lose call context across .then() | Stack traces are more accurate and readable |
| Conditional async logic | Nested .then() chains, hard to read | Simple if/else inside the async function |
| Parallel execution | Promise.all() directly | await Promise.all([...]) — same tool, cleaner syntax |
| Learning curve | Must understand Promise constructor and chaining | Slightly easier entry point, but requires Promise knowledge |
| Top-level usage | Works anywhere | Requires async function wrapper (or top-level await in modules) |
| Return value | Returns a Promise — explicit | Returns a Promise — automatic, can surprise beginners |
🎯 Key Takeaways
- async functions always return a Promise — even if you return a plain value like a string or number, it gets wrapped in Promise.resolve() automatically.
- await only blocks the current async function's execution, not the JavaScript thread — the event loop continues running other code while it waits.
- Multiple consecutive awaits on independent operations is a performance bug — use Promise.all() to run independent async operations in parallel and cut total wait time to the slowest single request.
- fetch() does NOT reject on 4xx/5xx HTTP errors — you must manually check response.ok or response.status after every await fetch(...) call, or you'll silently miss server errors.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using await inside a non-async function — This causes a SyntaxError: 'await is only valid in async functions'. If you forget the async keyword on a function that contains await, nothing runs. Fix: always add async to any function that contains an await expression, including callbacks like array methods (e.g., arr.map(async (item) => await doSomething(item))).
- ✕Mistake 2: Awaiting inside a .forEach() loop and expecting it to work serially — forEach ignores returned Promises, so all iterations fire simultaneously AND the outer function doesn't wait for any of them. Symptom: code after the forEach runs before the async operations inside finish. Fix: replace forEach with a for...of loop and await inside it for serial execution, or use Promise.all(arr.map(async (item) => ...)) for parallel execution.
- ✕Mistake 3: Not handling rejected Promises from async functions — Calling an async function without await and without .catch() means any rejection becomes an unhandled Promise rejection. Symptom: silent failures in development, process crashes in Node.js 15+ production. Fix: always either await the call inside a try/catch, or append .catch() to the Promise returned by the async function call.
Interview Questions on This Topic
- QWhat is the difference between Promise.all() and Promise.allSettled()? When would you choose one over the other?
- QIf you have three independent API calls in an async function and you write three consecutive await statements, what's the performance problem — and how would you fix it?
- QCan you use await outside of an async function? What happens if you try, and is there any context where top-level await is valid?
Frequently Asked Questions
Can I use async await with forEach in JavaScript?
Technically yes, but it almost certainly won't do what you expect. forEach ignores returned Promises, so await inside a forEach callback doesn't pause the outer function — all iterations fire at once and your code moves on before any finish. Use a for...of loop with await for sequential processing, or Promise.all() with .map() for parallel processing.
What's the difference between async await and Promises in JavaScript?
async/await is built directly on top of Promises — it doesn't replace them. async/await is syntactic sugar that lets you write Promise-based code in a linear, synchronous-looking style. Under the hood, every await is a .then() call. You still need to understand Promises to use async/await effectively, especially for parallel patterns like Promise.all().
Does await block the entire JavaScript program?
No — this is a crucial distinction. await pauses only the specific async function it's inside. The JavaScript event loop keeps running, handling other callbacks, timers, and events while your async function waits. This is what makes Node.js able to handle thousands of concurrent requests despite being single-threaded.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.