Async/Await
Introduction to Async/Await
Async/Await is a modern JavaScript syntax introduced in ES2017 (ES8) that makes asynchronous code look and behave like synchronous code. Before async/await, developers relied on callbacks and Promise chains with .then() and .catch() to handle asynchronous operations. While Promises were a significant improvement over callback hell, deeply nested .then() chains could still become difficult to read and maintain. Async/await solves this problem by providing a cleaner, more intuitive way to work with Promises.
At its core, async/await is syntactic sugar built on top of Promises. It does not replace Promises -- it makes them easier to use. An async function always returns a Promise, and the await keyword pauses the execution of the function until the awaited Promise resolves or rejects. This allows you to write asynchronous code that reads from top to bottom, just like synchronous code, making it significantly easier to understand, debug, and maintain.
Declaring an Async Function
To create an async function, you simply place the async keyword before the function declaration. This tells JavaScript that the function will contain asynchronous operations and that it should always return a Promise. If the function returns a value directly, JavaScript automatically wraps that value in a resolved Promise.
Example: Basic Async Function Declaration
// Declaring an async function
async function greetUser() {
return 'Hello, World!';
}
// The function returns a Promise
greetUser().then(message => {
console.log(message); // Output: Hello, World!
});
// You can also verify it returns a Promise
console.log(greetUser() instanceof Promise); // Output: true
Even though the function above looks like it returns a simple string, JavaScript wraps the return value in Promise.resolve('Hello, World!') behind the scenes. This is why you can chain .then() on the result. If you throw an error inside an async function, it automatically returns a rejected Promise.
Example: Async Function That Returns a Rejected Promise
async function failingFunction() {
throw new Error('Something went wrong!');
}
failingFunction().catch(error => {
console.log(error.message); // Output: Something went wrong!
});
The Await Keyword
The await keyword can only be used inside an async function (with one exception we will cover later). When you place await before a Promise, JavaScript pauses the execution of the async function at that point and waits for the Promise to settle. If the Promise resolves, await returns the resolved value. If the Promise rejects, await throws the rejection reason as an error, which you can catch with try/catch.
Example: Using Await with a Promise
function fetchUserData() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ id: 1, name: 'Edrees', role: 'Developer' });
}, 2000);
});
}
async function displayUser() {
console.log('Fetching user data...');
const user = await fetchUserData(); // Pauses here for 2 seconds
console.log('User:', user.name); // Output after 2s: User: Edrees
console.log('Role:', user.role); // Output: Role: Developer
}
displayUser();
await keyword only pauses the execution of the async function it is inside. The rest of your program continues to run normally. Other event handlers, timers, and functions are not blocked while the async function is waiting.Async/Await vs .then() Chains
To truly appreciate async/await, let us compare the same asynchronous operation written with .then() chains versus async/await. Consider a scenario where you need to fetch a user, then fetch their orders, and finally fetch the details of the most recent order.
Example: Using .then() Chains
function getUser(userId) {
return fetch('/api/users/' + userId).then(res => res.json());
}
function getOrders(userId) {
return fetch('/api/users/' + userId + '/orders').then(res => res.json());
}
function getOrderDetails(orderId) {
return fetch('/api/orders/' + orderId).then(res => res.json());
}
// Using .then() chains -- gets deeply nested
getUser(1)
.then(user => {
console.log('User:', user.name);
return getOrders(user.id);
})
.then(orders => {
const latestOrder = orders[0];
console.log('Latest order:', latestOrder.id);
return getOrderDetails(latestOrder.id);
})
.then(details => {
console.log('Order details:', details);
})
.catch(error => {
console.error('Error:', error.message);
});
Example: Using Async/Await -- Much Cleaner
async function displayLatestOrder() {
try {
const user = await getUser(1);
console.log('User:', user.name);
const orders = await getOrders(user.id);
const latestOrder = orders[0];
console.log('Latest order:', latestOrder.id);
const details = await getOrderDetails(latestOrder.id);
console.log('Order details:', details);
} catch (error) {
console.error('Error:', error.message);
}
}
displayLatestOrder();
The async/await version reads like a simple step-by-step procedure. Each line clearly shows what happens next. There is no nesting, no callback indentation, and no confusion about the flow of data. Error handling is also centralized in a single try/catch block rather than a single .catch() at the end of a chain.
Error Handling with Try/Catch
One of the most powerful features of async/await is that you can use standard try/catch blocks for error handling, just like you would in synchronous code. This replaces the .catch() method used with Promise chains and gives you fine-grained control over how different errors are handled.
Example: Comprehensive Error Handling
async function fetchUserProfile(userId) {
try {
const response = await fetch('/api/users/' + userId);
// Check if the HTTP response was successful
if (!response.ok) {
throw new Error('HTTP error! Status: ' + response.status);
}
const user = await response.json();
console.log('Profile loaded:', user.name);
return user;
} catch (error) {
if (error.name === 'TypeError') {
// Network error (no connection, DNS failure, etc.)
console.error('Network error: Could not reach the server.');
} else {
// HTTP error or other errors
console.error('Failed to load profile:', error.message);
}
return null; // Return a default value on failure
}
}
// Using the function
async function main() {
const profile = await fetchUserProfile(42);
if (profile) {
console.log('Welcome back, ' + profile.name);
} else {
console.log('Please try again later.');
}
}
main();
You can also use a finally block to run cleanup code regardless of whether the operation succeeded or failed. This is useful for hiding loading indicators, closing connections, or resetting state.
Example: Try/Catch/Finally Pattern
async function loadDashboardData() {
const loadingSpinner = document.getElementById('spinner');
loadingSpinner.style.display = 'block';
try {
const stats = await fetch('/api/dashboard/stats').then(r => r.json());
const notifications = await fetch('/api/notifications').then(r => r.json());
renderDashboard(stats, notifications);
} catch (error) {
showErrorMessage('Failed to load dashboard data.');
console.error(error);
} finally {
// This runs whether the try block succeeded or failed
loadingSpinner.style.display = 'none';
}
}
try/catch blocks inside an async function to handle different errors separately. For example, you might want to continue loading the dashboard even if notifications fail to load. Wrap each independent operation in its own try/catch to prevent one failure from stopping everything.Multiple Awaits: Sequential vs Parallel Execution
A common mistake with async/await is accidentally making operations sequential when they could run in parallel. When you place multiple await statements one after another, each one waits for the previous one to complete before starting. If those operations are independent of each other, you are wasting time.
Example: Sequential Execution (Slower)
async function loadPageData() {
console.time('sequential');
// These three requests run one after another
const users = await fetch('/api/users').then(r => r.json()); // 1 second
const posts = await fetch('/api/posts').then(r => r.json()); // 1 second
const comments = await fetch('/api/comments').then(r => r.json()); // 1 second
console.timeEnd('sequential'); // Total: ~3 seconds
return { users, posts, comments };
}
Example: Parallel Execution with Promise.all (Faster)
async function loadPageData() {
console.time('parallel');
// All three requests start at the same time
const [users, posts, comments] = await Promise.all([
fetch('/api/users').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/comments').then(r => r.json())
]);
console.timeEnd('parallel'); // Total: ~1 second (the slowest request)
return { users, posts, comments };
}
Promise.all() takes an array of Promises and returns a single Promise that resolves when all of them have resolved. The result is an array of resolved values in the same order as the input Promises. If any one of the Promises rejects, Promise.all() immediately rejects with that error.
Example: Promise.allSettled for Graceful Handling
async function loadPageDataSafely() {
const results = await Promise.allSettled([
fetch('/api/users').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/comments').then(r => r.json())
]);
// Each result has a status: 'fulfilled' or 'rejected'
const users = results[0].status === 'fulfilled' ? results[0].value : [];
const posts = results[1].status === 'fulfilled' ? results[1].value : [];
const comments = results[2].status === 'fulfilled' ? results[2].value : [];
return { users, posts, comments };
}
Promise.all() when one failing request should not block others. If you fetch user profile, notifications, and recommendations, a failure in recommendations should not prevent the profile from loading. Use Promise.allSettled() instead, which waits for all Promises to complete regardless of whether they resolve or reject.Top-Level Await
In modern JavaScript (ES2022), you can use await at the top level of ES modules without wrapping it in an async function. This is called top-level await. It is supported in modern browsers and Node.js (version 14.8+ with ES modules). Top-level await is particularly useful for module initialization that depends on asynchronous operations.
Example: Top-Level Await in ES Modules
// config.mjs -- an ES module
const response = await fetch('/api/config');
const config = await response.json();
export default config;
// app.mjs -- importing the module
import config from './config.mjs';
// config is already loaded and available
console.log('App running on port:', config.port);
.mjs extension or type="module" in your <script> tag or package.json). It does not work in regular scripts or CommonJS modules. When a module uses top-level await, any module that imports it will wait for the async operation to complete before executing.Async Arrow Functions
You can combine the async keyword with arrow function syntax for more concise function declarations. Async arrow functions are especially useful for callbacks, event handlers, and short utility functions.
Example: Async Arrow Function Syntax
// Regular async arrow function
const fetchUser = async (id) => {
const response = await fetch('/api/users/' + id);
return response.json();
};
// Async arrow function with single parameter (no parentheses needed)
const getPost = async id => {
const response = await fetch('/api/posts/' + id);
return response.json();
};
// Using async arrow function as a callback
const userIds = [1, 2, 3, 4, 5];
// Note: This does NOT work as expected with forEach!
// forEach does not await async callbacks
userIds.forEach(async (id) => {
const user = await fetchUser(id);
console.log(user.name); // Order is unpredictable
});
// Use for...of loop instead for sequential execution
async function loadAllUsers() {
for (const id of userIds) {
const user = await fetchUser(id);
console.log(user.name); // Guaranteed sequential order
}
}
// Or use Promise.all for parallel execution
async function loadAllUsersParallel() {
const users = await Promise.all(
userIds.map(id => fetchUser(id))
);
users.forEach(user => console.log(user.name));
}
Async Methods in Classes
Async functions work seamlessly as methods inside JavaScript classes. This is common when building services, repositories, or controllers in object-oriented JavaScript. You can also use async methods in object literals.
Example: Async Methods in a Class
class UserService {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
async getUser(id) {
const response = await fetch(this.baseUrl + '/users/' + id);
if (!response.ok) {
throw new Error('User not found');
}
return response.json();
}
async createUser(userData) {
const response = await fetch(this.baseUrl + '/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
});
return response.json();
}
async updateUser(id, updates) {
const response = await fetch(this.baseUrl + '/users/' + id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
return response.json();
}
async deleteUser(id) {
const response = await fetch(this.baseUrl + '/users/' + id, {
method: 'DELETE'
});
return response.ok;
}
}
// Using the class
const userService = new UserService('https://api.example.com');
async function manageUsers() {
const newUser = await userService.createUser({
name: 'Ahmad',
email: 'ahmad@example.com'
});
console.log('Created user:', newUser.id);
const user = await userService.getUser(newUser.id);
console.log('Fetched user:', user.name);
}
The Async IIFE Pattern
Before top-level await was available, developers used Immediately Invoked Function Expressions (IIFE) with async to run await at the top level of their scripts. This pattern is still useful in environments that do not support top-level await or when you want to encapsulate async initialization logic.
Example: Async IIFE Pattern
// Async IIFE -- runs immediately
(async () => {
try {
const response = await fetch('/api/config');
const config = await response.json();
console.log('App configuration loaded:', config);
// Initialize the application
initializeApp(config);
} catch (error) {
console.error('Failed to start app:', error.message);
}
})();
// Named async IIFE for better stack traces
(async function bootstrap() {
const db = await connectToDatabase();
const server = await startServer(db);
console.log('Server running on port', server.port);
})();
Common Mistakes with Async/Await
Even experienced developers make mistakes with async/await. Here are the most common pitfalls and how to avoid them.
Mistake 1: Forgetting the Await Keyword
If you call an async function without await, you get a Promise object instead of the resolved value. This can lead to bugs that are hard to track down because the code does not throw an error -- it just produces unexpected results.
Example: Forgetting Await
async function getUserName(id) {
const response = await fetch('/api/users/' + id);
const user = await response.json();
return user.name;
}
async function displayGreeting() {
// BUG: Missing await! name is a Promise, not a string
const name = getUserName(1);
console.log('Hello, ' + name); // Output: Hello, [object Promise]
// CORRECT: Use await to get the actual value
const correctName = await getUserName(1);
console.log('Hello, ' + correctName); // Output: Hello, Edrees
}
Mistake 2: Using Unnecessary Async
Marking a function as async when it does not use await is unnecessary. It adds overhead (wrapping the return value in a Promise) without any benefit. If you are simply returning another Promise, you do not need async.
Example: Unnecessary Async
// UNNECESSARY: This async does nothing useful
async function getUser(id) {
return fetch('/api/users/' + id).then(r => r.json());
}
// BETTER: Just return the Promise directly
function getUser(id) {
return fetch('/api/users/' + id).then(r => r.json());
}
// APPROPRIATE: Using async because we need await
async function getUser(id) {
const response = await fetch('/api/users/' + id);
if (!response.ok) throw new Error('User not found');
return response.json();
}
Mistake 3: Not Handling Errors
If you do not catch errors from an awaited Promise, the async function will return a rejected Promise. If nobody catches that rejection either, you will get an unhandled promise rejection warning.
Example: Unhandled Errors
// BAD: No error handling
async function riskyOperation() {
const data = await fetch('/api/might-fail'); // Could throw!
return data.json();
}
// This will cause an unhandled promise rejection if the fetch fails
riskyOperation();
// GOOD: Handle errors properly
async function safeOperation() {
try {
const data = await fetch('/api/might-fail');
return data.json();
} catch (error) {
console.error('Operation failed:', error.message);
return null;
}
}
// Or handle errors at the call site
riskyOperation().catch(error => {
console.error('Caught at call site:', error.message);
});
Mistake 4: Await Inside Loops When Parallel is Better
Example: Inefficient Loop vs Parallel Execution
const productIds = [101, 102, 103, 104, 105];
// SLOW: Each request waits for the previous one
async function loadProductsSequential() {
const products = [];
for (const id of productIds) {
const product = await fetch('/api/products/' + id).then(r => r.json());
products.push(product);
}
return products; // Takes 5x longer than necessary
}
// FAST: All requests start simultaneously
async function loadProductsParallel() {
const products = await Promise.all(
productIds.map(id =>
fetch('/api/products/' + id).then(r => r.json())
)
);
return products; // Takes only as long as the slowest request
}
await inside Array.forEach(). The forEach method does not understand Promises and will not wait for your async callback to finish. Use a for...of loop for sequential execution or Promise.all() with map() for parallel execution.Real-World Patterns
Let us look at practical examples that you will encounter in real web development projects.
Pattern 1: Data Fetching with Loading and Error States
Example: Complete Data Fetching Pattern
class DataLoader {
constructor() {
this.loading = false;
this.error = null;
this.data = null;
}
async load(url) {
this.loading = true;
this.error = null;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error('Server returned ' + response.status);
}
this.data = await response.json();
return this.data;
} catch (error) {
this.error = error.message;
this.data = null;
throw error;
} finally {
this.loading = false;
}
}
}
// Usage
const loader = new DataLoader();
async function renderPage() {
try {
const articles = await loader.load('/api/articles');
document.getElementById('content').innerHTML =
articles.map(a => '<h3>' + a.title + '</h3>').join('');
} catch (error) {
document.getElementById('content').innerHTML =
'<p class="error">Failed to load articles: ' + loader.error + '</p>';
}
}
Pattern 2: Retry Logic for Unreliable Networks
Example: Fetch with Automatic Retry
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
return await response.json();
} catch (error) {
lastError = error;
console.warn('Attempt ' + attempt + ' failed: ' + error.message);
if (attempt < maxRetries) {
// Wait before retrying: 1s, 2s, 4s (exponential backoff)
const delay = Math.pow(2, attempt - 1) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw new Error('Failed after ' + maxRetries + ' attempts: ' + lastError.message);
}
// Usage
async function loadData() {
try {
const data = await fetchWithRetry('/api/important-data');
console.log('Data loaded successfully:', data);
} catch (error) {
console.error('All retries failed:', error.message);
}
}
Pattern 3: User Authentication Flow
Example: Complete Authentication Flow
class AuthService {
constructor(apiBase) {
this.apiBase = apiBase;
this.token = null;
this.user = null;
}
async login(email, password) {
try {
const response = await fetch(this.apiBase + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Login failed');
}
const data = await response.json();
this.token = data.token;
localStorage.setItem('authToken', this.token);
// Fetch user profile after successful login
this.user = await this.getProfile();
return this.user;
} catch (error) {
this.token = null;
this.user = null;
throw error;
}
}
async getProfile() {
if (!this.token) {
throw new Error('Not authenticated');
}
const response = await fetch(this.apiBase + '/auth/profile', {
headers: {
'Authorization': 'Bearer ' + this.token,
'Accept': 'application/json'
}
});
if (!response.ok) {
if (response.status === 401) {
this.logout();
throw new Error('Session expired. Please log in again.');
}
throw new Error('Failed to load profile');
}
return response.json();
}
async logout() {
try {
if (this.token) {
await fetch(this.apiBase + '/auth/logout', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + this.token }
});
}
} finally {
this.token = null;
this.user = null;
localStorage.removeItem('authToken');
}
}
async refreshToken() {
const response = await fetch(this.apiBase + '/auth/refresh', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + this.token }
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
const data = await response.json();
this.token = data.token;
localStorage.setItem('authToken', this.token);
return this.token;
}
}
// Usage
const auth = new AuthService('https://api.example.com');
async function handleLogin(formData) {
const submitButton = document.getElementById('loginBtn');
const errorDiv = document.getElementById('loginError');
submitButton.disabled = true;
errorDiv.textContent = '';
try {
const user = await auth.login(formData.email, formData.password);
console.log('Welcome, ' + user.name);
window.location.href = '/dashboard';
} catch (error) {
errorDiv.textContent = error.message;
} finally {
submitButton.disabled = false;
}
}
Pattern 4: Sequential File Processing
Example: Processing Files One at a Time
async function processFiles(files) {
const results = [];
for (const file of files) {
try {
console.log('Processing:', file.name);
const content = await readFileAsync(file);
const processed = await uploadFile(file.name, content);
results.push({ name: file.name, status: 'success', url: processed.url });
} catch (error) {
results.push({ name: file.name, status: 'failed', error: error.message });
}
}
return results;
}
function readFileAsync(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('Failed to read ' + file.name));
reader.readAsArrayBuffer(file);
});
}
async function uploadFile(name, content) {
const formData = new FormData();
formData.append('file', new Blob([content]), name);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
return response.json();
}
Combining Async/Await with Promise Utilities
Async/await works beautifully with other Promise utility methods like Promise.race() and Promise.any().
Example: Timeout with Promise.race
function timeout(ms) {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error('Request timed out after ' + ms + 'ms')), ms);
});
}
async function fetchWithTimeout(url, ms = 5000) {
try {
// Race between the fetch and a timeout
const response = await Promise.race([
fetch(url),
timeout(ms)
]);
return await response.json();
} catch (error) {
console.error(error.message);
return null;
}
}
// Usage
const data = await fetchWithTimeout('/api/slow-endpoint', 3000);
Example: Fastest Mirror with Promise.any
async function fetchFromFastestMirror(path) {
const mirrors = [
'https://mirror1.example.com',
'https://mirror2.example.com',
'https://mirror3.example.com'
];
try {
// Returns the result from whichever mirror responds first
const response = await Promise.any(
mirrors.map(mirror => fetch(mirror + path))
);
return await response.json();
} catch (error) {
// All mirrors failed
throw new Error('All mirrors are down');
}
}
Async Generators and for-await-of
JavaScript also supports async generators, which combine async/await with generator functions. The for await...of loop lets you iterate over asynchronous data streams.
Example: Async Generator for Paginated API
async function* fetchAllPages(baseUrl) {
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await fetch(baseUrl + '?page=' + page);
const data = await response.json();
yield data.items;
hasMore = data.hasNextPage;
page++;
}
}
// Using for-await-of to consume the async generator
async function loadAllItems() {
const allItems = [];
for await (const items of fetchAllPages('/api/products')) {
allItems.push(...items);
console.log('Loaded ' + allItems.length + ' items so far...');
}
console.log('Total items loaded:', allItems.length);
return allItems;
}
await in a loop versus Promise.all(), consider rate limiting. Many APIs limit how many requests you can make per second. In those cases, sequential execution or batched parallel execution (processing groups of 5-10 at a time) is more appropriate than firing all requests at once.Practice Exercise
Build a task manager application that demonstrates all the async/await patterns covered in this lesson. Create a TaskManager class with async methods for loadTasks(), addTask(task), updateTask(id, updates), and deleteTask(id). Each method should use try/catch for error handling and a finally block to update a loading state. Add a loadDashboard() method that uses Promise.all() to load tasks, user profile, and notifications in parallel. Add a syncAllTasks(tasks) method that processes an array of tasks sequentially using a for...of loop (simulating rate-limited API calls). Finally, create a fetchWithTimeout() utility function using Promise.race() that rejects if a request takes longer than 5 seconds. Test all methods and verify that parallel fetching is faster than sequential fetching by measuring execution time with console.time().