A while ago when I first tried to do things asynchronously with JavaScript, things got overwhelming fast. More than once I produced spaghetti code riddled with nested callbacks that looked like a sideways pyramid showing the way to a debugging hell.
When JavaScript Promises came into my life, things got a bit easier. Suddenly it wasn’t that hard to deal with asynchronous operations but unfortunately the syntax still felt clunky. That’s why learning about async/await was such a big deal for me.
With async/await I essentially changed the way I write JavaScript as I could make asynchronous code look and behave almost like synchronous code.
In this guide, I’ll walk you through everything you need to know about async/await, from the fundamentals to real-world applications with the hopes that you can start using it in your projects today.
Why Asynchronous Programming is Important in JavaScript
As you may already know, JavaScript runs on a single thread, which means it can only execute one piece of code at a time. Without asynchronous programming, any time-consuming operation (like fetching data from an API, reading a file, or waiting for user input) would completely freeze your application.
Imagine that you’re building a weather app that needs to fetch data from an API. If JavaScript couldn’t handle this asynchronously, your entire interface would freeze until the API responded with some data. Users wouldn’t be able to click buttons, scroll, or interact with anything, creating a scenario obviously unacceptable in modern web development.
With asynchronous programming in JavaScript we can:
- Make network requests without blocking the user interface
- Handle multiple operations simultaneously
- Respond to user interactions while background tasks are running
- Create smooth, responsive applications that feel fast and fluid
Understanding the JavaScript Event Loop
Before going deep into async/await, it’s a good idea to understand how JavaScript handles asynchronous operations under the hood with the event loop, the mechanism that makes it possible.
The Call Stack
The call stack is where JavaScript keeps track of function calls. When you call a function, it gets added to the top of the stack and when the function returns, it gets removed from the stack:
// Define a function that simply logs a line
function first() {
// This message prints first because `first()` is invoked inside `second()`
console.log("First function");
}
// Second function that calls `first` and then logs its own line
function second() {
// Push `first` onto the call stack
first();
// After `first` returns, log a second message
console.log("Second function");
}
// Kick things off by calling `second`
second();
// Output:
// First function
// Second functionWeb APIs and the Event Queue
When you use asynchronous functions like setTimeout, fetch, or DOM event listeners, these tasks don’t run immediately. Instead, they are managed by Web APIs and their callbacks get placed into an event queue, waiting to be executed.
The event loop then continuously checks: “Is the call stack empty? If so, take the first callback from the event queue and put it on the call stack.”
This can be demonstrated with a simple example:
// Synchronous log, executed immediately
console.log("Start");
// Register an asynchronous task handled by the browser
setTimeout(() => {
// This line only runs after the current stack is empty
console.log("Timeout callback");
}, 0);
// Another synchronous log, still on the original stack
console.log("End");
// Output order:
// Start
// End
// Timeout callbackEven though we’ve set the timeout to 0 milliseconds, “End” prints before “Timeout callback” because the setTimeout callback must wait for the call stack to be empty.
This is very useful because it allows the page to stay responsive so users can still interact with it while they wait.
Promises Recap
As I mentioned in the introduction, Promises were the primary way to handle asynchronous operations in JavaScript before async/await. Let me quickly recap how they work to explain how async/await is built on top of them.
Basic Promise Syntax
Promises were designed as a way to handle asynchronous code. They represent tasks that will eventually be completed either successfully (resolve) or unsuccessfully (reject).
This is how you’d create and use a Promise:
// Create a Promise that resolves with fake user data
const fetchUserData = new Promise((resolve, reject) => {
setTimeout(() => {
// Simulated server response
const userData = { id: 1, name: "John Doe" };
resolve(userData);
// To test a failure path, comment out the line above and uncomment below
// reject(new Error("Failed to fetch user"));
}, 1000);
});
// Consume the Promise
fetchUserData
.then(user => {
console.log("User data:", user);
})
.catch(error => {
console.error("Error:", error);
});Promise Chaining
One of the main advantages of Promises over callbacks is that they allow us to chain operations and if we chain operations, each step can pass its result to the next:
// Fetch a user, then fetch that user’s posts
fetch('/api/user/1')
// Parse the first response
.then(response => response.json())
.then(user => {
console.log("User:", user);
// Fetch user posts next
return fetch(`/api/posts/${user.id}`);
})
// Parse posts
.then(response => response.json())
.then(posts => console.log("User posts:", posts))
// Catch any failure in the chain
.catch(error => console.error("Error:", error));This is definitely much better than the old callback hell but it can still become messy with complex chains. These are the situations where async/await shines!
Understanding Async/Await?
Async/await provides a cleaner and more intuitive syntax to write asynchronous code that looks and reads like synchronous code. This is what is commonly known as “syntactic sugar”. Under the hood, it’s all Promises but it makes our lives way easier.
It consists of two keywords:
- async: Declares that a function will handle asynchronous operations
- await: Pauses the execution of the function until a Promise resolves
Basic Syntax
In this example we can see async/await in action simplifying Promises and making our asynchronous code look synchronous:
// Define an async function that loads one user
async function fetchUserData() {
// Wait until the HTTP request completes
const response = await fetch('/api/user/1');
// Wait until the response body is parsed
const user = await response.json();
return user;
}
// Call the async function and handle its result
fetchUserData()
.then(user => console.log(user))
.catch(error => console.error(error));Converting Promise Chains to Async/Await
We can rewrite Promise chains using async/await for improved readability. Let’s convert the Promise chain example from earlier to illustrate it:
// With Promises
function getUserAndPosts() {
return fetch('/api/user/1')
.then(response => response.json())
.then(user => {
console.log("User:", user);
return fetch(`/api/posts/${user.id}`);
})
.then(response => response.json())
.then(posts => {
console.log("User posts:", posts);
return posts;
});
}
// With async/await
async function getUserAndPosts() {
// Wait for user fetch
const userResponse = await fetch('/api/user/1');
const user = await userResponse.json();
console.log("User:", user);
// Wait for posts fetch, using the user id
const postsResponse = await fetch(`/api/posts/${user.id}`);
const posts = await postsResponse.json();
console.log("User posts:", posts);
// Caller still receives a Promise
return posts;
}The async/await version is without a doubt more readable and easier to understand. This can make your transition to JavaScript a bit easier if you are a developer coming from a synchronous programming background.
Real-World Examples
Let me share some practical examples that I have used in previous projects. I’ll be simplifying the code and omitting some functional elements to better illustrate how async/await work.
Example 1: Making API Calls
This is how I’d typically handle API calls in modern JavaScript applications. Pay special attention to the error handling:
// Make a JSON request and throw if the server replies 4xx or 5xx
async function apiCall(url, options = {}) {
try {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
});
// Convert HTTP error codes into thrown errors
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
// Parse and return JSON
return await response.json();
} catch (error) {
console.error('API call failed:', error);
throw error;
}
}
// Using the utility function
async function loadUserProfile(userId) {
try {
const user = await apiCall(`/api/users/${userId}`);
const preferences = await apiCall(`/api/users/${userId}/preferences`);
return {
...user,
preferences
};
} catch (error) {
console.error('Failed to load user profile:', error);
return null;
}
}Example 2: Handling User Interactions
In many apps we need to send a form and give the user fast feedback. Using async and our apiCall helper from the previous example keeps the handler tidy.
// Attach this to the <form> onsubmit event
async function handleFormSubmit(event) {
// Stop the browser from reloading the page
event.preventDefault();
const submitButton = event.target.querySelector('button[type="submit"]');
const formData = new FormData(event.target);
// Show a loading state
submitButton.disabled = true;
submitButton.textContent = 'Submitting...';
try {
await apiCall('/api/contact', { method: 'POST', body: formData });
alert('Message sent successfully!');
// Clear the form
event.target.reset();
} catch (error) {
alert('Failed to send message. Please try again.');
} finally {
// Always re‑enable the button, success or fail
submitButton.disabled = false;
submitButton.textContent = 'Submit';
}
}Example 3: Creating Delays and Timeouts
Sometimes servers are busy so introducing delays can be helpful. A retry logic with exponential backoff allows us to wait and then try again.
This helper allows us to delay increasing the wait time after each failure.
// Small helper: returns a Promise that resolves after 'ms' milliseconds
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
// Fetch with up to 'maxRetries' attempts
async function fetchWithRetry(url, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url);
// If the server replied successfully, stop retrying
if (response.ok) {
return await response.json();
}
// If this was the last try, throw an error so the caller knows
if (attempt === maxRetries) {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
console.log(`Attempt ${attempt} failed:`, error.message);
// Give up if we have no retries left
if (attempt === maxRetries) {
throw error;
}
// Wait 1s, 2s, 4s, ... before trying again
await delay(1000 * Math.pow(2, attempt - 1));
}
}
}Example 4: Parallel Operations
Sometimes we have multiple independent tasks that can run simultaneously. If we execute them in parallel, we can save time and make our application faster.
For this to work, it’s important that the asynchronous operations don’t depend on each other, like in this example:
// Sequential approach (slower)
async function loadUserDataSequential(userId) {
// Fetch user first
const user = await apiCall(`/api/users/${userId}`);
// Fetch posts next
const posts = await apiCall(`/api/users/${userId}/posts`);
// Finally fetch friends
const friends = await apiCall(`/api/users/${userId}/friends`);
return { user, posts, friends };
}
// Parallel approach (faster)
async function loadUserDataParallel(userId) {
// Start fetching all together
const [user, posts, friends] = await Promise.all([
apiCall(`/api/users/${userId}`),
apiCall(`/api/users/${userId}/posts`),
apiCall(`/api/users/${userId}/friends`)
]);
return { user, posts, friends };
}
// Parallel with individual error handling
async function loadUserDataWithFallbacks(userId) {
// Allows each call to fail independently
const results = await Promise.allSettled([
apiCall(`/api/users/${userId}`),
apiCall(`/api/users/${userId}/posts`),
apiCall(`/api/users/${userId}/friends`)
]);
return {
// Handle failure
user: results[0].status === 'fulfilled' ? results[0].value : null,
posts: results[1].status === 'fulfilled' ? results[1].value : [],
friends: results[2].status === 'fulfilled' ? results[2].value : []
};
}Error Handling with Try/Catch
One of the biggest advantages of async/await is that it can cleanly handle errors using traditional try/catch blocks.
Basic Error Handling
The basic syntax is very straightforward. We try to perform an action and we catch errors if they occur. The code is pretty much self-explanatory.
// Fetch a single user and surface clear errors
async function fetchUserData(userId) {
try {
// Make the HTTP request
const response = await fetch(`/api/users/${userId}`);
// Convert non‑200 responses into a thrown error
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status}`);
}
// Parse and return the response body
const user = await response.json();
return user;
} catch (error) {
console.error('Error fetching user:', error);
// You can handle different types of errors differently
if (error.name === 'TypeError') {
// Network error
throw new Error('Network connection failed');
} else {
// Re-throw other errors
throw error;
}
}
}Handling Multiple Operations
When we perform multiple related tasks, handling errors becomes super important to ensure data consistency.
For example, here we process a refund to the client if they had already paid and we run into an error processing their order.
// Process a full order, including validation, payment, order creation and email
async function processUserOrder(userId, orderData) {
let payment;
try {
// 1. Load and validate the user
const user = await fetchUserData(userId);
if (!user.active) {
throw new Error('User account is not active');
}
// 2. Charge the customer
payment = await processPayment(orderData.paymentInfo);
// 3. Create the order record in the database
const order = await createOrder({
userId: user.id,
items: orderData.items,
paymentId: payment.id
});
// 4. Notify the user
await sendOrderConfirmation(user.email, order);
return order;
} catch (error) {
console.error('Order processing failed:', error);
// If money was taken, attempt a refund before re‑throwing
if (payment?.id) {
await refundPayment(payment.id).catch(console.error);
}
throw new Error(`Order processing failed: ${error.message}`);
}
}Custom Error Types
For more sophisticated error handling, it’s possible to create custom error classes. This can make a difference in bigger projects where we expect many errors to happen.
class APIError extends Error {
constructor(message, status, response) {
super(message);
this.name = 'APIError';
this.status = status;
this.response = response;
}
}
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = 'ValidationError';
this.field = field;
}
}
async function createUser(userData) {
try {
// Validate input locally before hitting the server
if (!userData.email) {
throw new ValidationError('Email is required', 'email');
}
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
});
// Translate non‑200 codes into an APIError
if (!response.ok) {
const errorData = await response.json();
throw new APIError(errorData.message, response.status, errorData);
}
return await response.json();
} catch (error) {
if (error instanceof ValidationError) {
showFieldError(error.field, error.message);
} else if (error instanceof APIError) {
const msg = error.status === 409 ? 'User already exists' : 'Server error occurred';
showError(msg);
} else {
console.error('Unexpected error:', error);
showError('An unexpected error occurred');
}
throw error;
}
}Best Practices for Clean Async Code
Throughout the years, I’ve adopted certain habits that help keep my code clean and maintainable. I strongly encourage you to keep the following advice in mind when you work on asynchronous applications.
1. Always Handle Errors
Never use await without proper error handling. Even if you don’t expect errors, they can and will happen:
// Poor pattern: any network error will crash the caller
async function badExample() {
const data = await fetch('/api/data');
return data.json();
}
// Safer pattern: convert bad HTTP codes into thrown errors
async function goodExample() {
try {
const response = await fetch('/api/data');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Failed to fetch data:', error);
// Or return a default value instead
throw error;
}
}2. Use Promise.all for Parallel Operations
When operations don’t depend on each other it’s definitely a good idea to run them in parallel:
// Sequential (slow)
async function sequential() {
const user = await fetchUser();
const posts = await fetchPosts();
const comments = await fetchComments();
return { user, posts, comments };
}
// Parallel (fast)
async function parallel() {
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments()
]);
return { user, posts, comments };
}3. Keep Async Functions Focused
As you probably already know, this doesn’t apply only to asynchronous functions. In general, breaking complex operations into smaller, focused functions is a good idea. However, with asynchronous operations the real-world impact of separating concerns is a bit more obvious.
// Complex function
async function complexUserSetup(userData) {
// Validation logic...
// User creation logic...
// Email sending logic...
// Profile setup logic...
// Analytics tracking...
}
// Better: Separate concerns
async function validateUserData(userData) {
// Validation logic only
}
async function createUserAccount(userData) {
// User creation logic only
}
async function sendWelcomeEmail(user) {
// Email logic only
}
async function setupUserProfile(userId, profileData) {
// Profile setup logic only
}
async function trackUserSignup(userId) {
// Analytics logic only
}
// Main function orchestrates the process
async function registerUser(userData, profileData) {
await validateUserData(userData);
const user = await createUserAccount(userData);
// Email, profile setup and analytics can happen at the same time
await Promise.all([
sendWelcomeEmail(user),
setupUserProfile(user.id, profileData),
trackUserSignup(user.id)
]);
return user;
}4. Use Descriptive Variable Names
Just like you’d do with all your code, try to make everything self-documenting with clear variable names:
// Hard to follow because names are cryptic
async function process(id) {
const r1 = await fetch(`/api/users/${id}`);
const d1 = await r1.json();
const r2 = await fetch(`/api/orders/${d1.id}`);
const d2 = await r2.json();
return d2;
}
// Easier to follow at a glance
async function getUserOrders(userId) {
const userResponse = await fetch(`/api/users/${userId}`);
const userData = await userResponse.json();
const ordersResponse = await fetch(`/api/orders/${userData.id}`);
const userOrders = await ordersResponse.json();
return userOrders;
}5. Consider Timeout Handling
For operations that might hang (mainly network requests) consider adding timeouts.
Timeouts can be particularly useful when you are interacting with third-party systems that you don’t control. If the external system takes too long to respond we can handle the situation gracefully like this:
// Return whichever settles first, the original promise or a timeout
function withTimeout(promise, timeoutMs) {
return Promise.race([
// The async task we really care about
promise,
// A competing promise that rejects after the given delay
new Promise((_, reject) => {
setTimeout(() => {
reject(new Error('Operation timed out'));
}, timeoutMs);
})
]);
}
// Fetch JSON from a URL, but give up after a fixed number of milliseconds
async function fetchWithTimeout(url, timeoutMs = 5000) {
try {
// Attempt the fetch within timeout limit
const response = await withTimeout(fetch(url), timeoutMs);
// Parse JSON if successful
return await response.json();
} catch (error) {
// Handle the specific timeout case
if (error.message === 'Operation timed out') {
console.error('Request timed out');
}
// Bubble every error upward so the caller can decide what to do
throw error;
}
}Wrapping Up
When async/await came into the scene, it truly revolutionized how we write asynchronous JavaScript because it makes code more readable, easier to debug, and less prone to errors.
I encourage you to start experimenting with async/await in your own projects. Begin with simple API calls, then gradually work your way up to more complex patterns like parallel operations and sophisticated error handling. The more you practice, the more natural it will become.
Don’t forget that async/await is built on Promises, so understanding both concepts will make you a more effective JavaScript developer. If you have some old Promise-based code you’ve already written, don’t be afraid to refactor it to use async/await. It will be a great opportunity to practice!
If you want to learn more about modern JavaScript development, consider Udacity’s Front End Web Developer Nanodegree to master advanced JavaScript concepts and modern development practices. If you find full-stack JavaScript development more interesting, the Full Stack JavaScript Developer Nanodegree covers both client and server-side JavaScript including async patterns.



