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 function

Web 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 callback

Even 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.

Alan Sánchez Pérez Peña
Alan Sánchez Pérez Peña
Alan is a seasoned developer and a Digital Marketing expert, with over a decade of software development experience. He has executed over 70,000+ project reviews at Udacity, and his contributions to course and project development have significantly enhanced the learning platform. Additionally, he provides strategic web consulting services, leveraging his front-end expertise with HTML, CSS, and JavaScript, alongside his Python skills to assist individuals and small businesses in optimizing their digital strategies. Connect with him on LinkedIn here: http://www.linkedin.com/in/alan247