Skip to main content

How to Use Async/Await in JavaScript

Promises transformed asynchronous JavaScript from callback hell into manageable chains. But even well-structured promise chains can become hard to read when operations are complex. async/await is syntactic sugar built on top of promises that lets you write asynchronous code that looks and behaves like synchronous code. Instead of chaining .then() calls, you simply await each asynchronous operation on its own line.

Under the hood, nothing changes. Async functions return promises. await pauses the function until a promise settles. Error handling uses familiar try...catch. But the readability improvement is dramatic, especially for sequential operations, error handling, and conditional logic. This guide covers every aspect of async/await, from basic syntax through advanced patterns, and highlights the common mistakes that lead to performance problems and silent bugs.

The async Keyword: Making Functions Return Promises

Adding async before a function declaration does two things: it guarantees the function always returns a promise, and it enables the use of await inside the function body.

Basic Syntax

async function greet() {
return "Hello!";
}

// Equivalent to:
function greet() {
return Promise.resolve("Hello!");
}

The return value is automatically wrapped in a promise:

async function getNumber() {
return 42;
}

const result = getNumber();
console.log(result); // Promise { 42 }
console.log(result instanceof Promise); // true

result.then(value => {
console.log(value); // 42
});

Any Return Value Becomes a Promise

If you return a non-promise value, it is wrapped in Promise.resolve(). If you return a promise, it is returned as-is (not double-wrapped):

async function returnValue() {
return "plain string"; // Wrapped in Promise.resolve("plain string")
}

async function returnPromise() {
return Promise.resolve("already a promise"); // Returned as-is
}

async function returnNothing() {
// Implicit return undefined → Promise.resolve(undefined)
}

console.log(await returnValue()); // "plain string"
console.log(await returnPromise()); // "already a promise"
console.log(await returnNothing()); // undefined

Throwing Inside async Functions

If an async function throws an error, the returned promise rejects with that error:

async function failingFunction() {
throw new Error("Something went wrong");
}

failingFunction().catch(err => {
console.log(err.message); // "Something went wrong"
});

// Equivalent to:
function failingFunction() {
return Promise.reject(new Error("Something went wrong"));
}

All Function Forms Work with async

// Function declaration
async function fetchData() { /* ... */ }

// Function expression
const fetchData = async function() { /* ... */ };

// Arrow function
const fetchData = async () => { /* ... */ };

// Arrow with concise body (implicit return)
const getUser = async (id) => fetch(`/api/users/${id}`).then(r => r.json());

// Method in an object
const api = {
async getUser(id) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
};

// Method in a class
class UserService {
async getUser(id) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
}

The await Keyword: Pausing for Promise Resolution

await pauses the execution of an async function until the awaited promise settles. If the promise fulfills, await returns the fulfilled value. If the promise rejects, await throws the rejection reason (which can be caught with try...catch).

Basic Usage

async function loadUser() {
console.log("Fetching user...");

const response = await fetch("/api/users/1");
// Execution pauses here until fetch completes

console.log("Parsing response...");

const user = await response.json();
// Execution pauses here until JSON parsing completes

console.log("User:", user.name);
return user;
}

Each await suspends the function. Other code outside this function continues to run while the function is paused. When the awaited promise resolves, the function resumes from exactly where it stopped.

await Does Not Block the Thread

This is critical. await pauses only the async function, not the entire JavaScript thread. Other code, event handlers, and other async functions continue to run normally:

async function slowOperation() {
console.log("Slow: starting");
await new Promise(r => setTimeout(r, 2000));
console.log("Slow: finished");
}

console.log("Before");
slowOperation(); // Starts but doesn't block
console.log("After"); // Runs immediately, doesn't wait 2 seconds

// Output:
// Before
// Slow: starting
// After
// (2 seconds later)
// Slow: finished

await with Non-Promise Values

If you await a non-promise value, it is wrapped in Promise.resolve() and resolved immediately (as a microtask):

async function example() {
const value = await 42; // Same as: await Promise.resolve(42)
console.log(value); // 42

const str = await "hello";
console.log(str); // "hello"
}

await with Thenable Objects

await works with any "thenable" (an object with a .then() method), not just native promises:

const thenable = {
then(resolve) {
setTimeout(() => resolve("from thenable"), 100);
}
};

async function example() {
const result = await thenable;
console.log(result); // "from thenable"
}

Sequential Operations with await

The most natural pattern. Each operation waits for the previous one to complete:

async function processOrder(orderId) {
const order = await fetchOrder(orderId);
const user = await fetchUser(order.userId);
const inventory = await checkInventory(order.items);
const payment = await processPayment(user, order.total);
const confirmation = await sendConfirmation(user.email, order);

return { order, user, payment, confirmation };
}

Each line waits for the previous one. This is appropriate when each step depends on the result of the previous step.

Error Handling with try...catch in Async Functions

Since await throws on rejection, you can use standard try...catch syntax for error handling. This is one of the biggest advantages of async/await over promise chains.

Basic Error Handling

async function loadData() {
try {
const response = await fetch("/api/data");

if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}

const data = await response.json();
return data;
} catch (error) {
console.error("Failed to load data:", error.message);
return null; // Return fallback value
}
}

Catching Specific Error Types

async function fetchUserProfile(userId) {
try {
const response = await fetch(`/api/users/${userId}`);

if (response.status === 404) {
throw new NotFoundError(`User ${userId} not found`);
}
if (response.status === 401) {
throw new AuthError("Authentication required");
}
if (!response.ok) {
throw new HttpError(response.status, response.statusText);
}

return await response.json();
} catch (error) {
if (error instanceof NotFoundError) {
console.log("User not found, showing default profile");
return getDefaultProfile();
}

if (error instanceof AuthError) {
console.log("Not authenticated, redirecting to login");
redirectToLogin();
return;
}

// Unknown error: rethrow
throw error;
}
}

try...catch...finally

async function loadWithSpinner() {
const spinner = document.getElementById("spinner");

try {
spinner.style.display = "block";

const data = await fetch("/api/data").then(r => r.json());
displayData(data);
return data;
} catch (error) {
displayError("Failed to load data. Please try again.");
throw error; // Rethrow so callers know it failed
} finally {
// Always hide spinner, whether success or failure
spinner.style.display = "none";
}
}

Error Handling at Different Levels

You can handle errors at the level of individual operations or at the level of the entire function:

async function buildDashboard(userId) {
// Critical: if user fetch fails, everything fails
const user = await fetchUser(userId);

// Non-critical: individual failures are recoverable
let orders;
try {
orders = await fetchOrders(userId);
} catch {
console.warn("Orders unavailable");
orders = [];
}

let notifications;
try {
notifications = await fetchNotifications(userId);
} catch {
console.warn("Notifications unavailable");
notifications = [];
}

return { user, orders, notifications };
}

// The caller handles the critical failure
try {
const dashboard = await buildDashboard(42);
render(dashboard);
} catch (error) {
showErrorPage(error.message);
}

Catching Errors from async Functions Without await

If you call an async function without await and without .catch(), rejected promises become unhandled:

// BAD: unhandled rejection if the function throws
async function riskyOperation() {
throw new Error("Boom!");
}

riskyOperation(); // UnhandledPromiseRejection!

// GOOD: handle the error
riskyOperation().catch(err => console.error(err));

// ALSO GOOD: await it inside try/catch
try {
await riskyOperation();
} catch (err) {
console.error(err);
}

await with Promise.all for Parallel Execution

When multiple asynchronous operations are independent, running them in parallel with Promise.all() is significantly faster than awaiting them sequentially.

The Performance Difference

// SEQUENTIAL: each operation waits for the previous one
// Total time: time(A) + time(B) + time(C) ≈ 3 seconds
async function sequential() {
const a = await fetchA(); // 1 second
const b = await fetchB(); // 1 second
const c = await fetchC(); // 1 second
return { a, b, c };
}

// PARALLEL: all operations run simultaneously
// Total time: max(time(A), time(B), time(C)) ≈ 1 second
async function parallel() {
const [a, b, c] = await Promise.all([
fetchA(), // 1 second
fetchB(), // 1 second } all running at the same time
fetchC() // 1 second
]);
return { a, b, c };
}

Real-World Parallel Fetching

async function loadUserDashboard(userId) {
// All three requests fire simultaneously
const [profile, orders, recommendations] = await Promise.all([
fetch(`/api/users/${userId}`).then(r => r.json()),
fetch(`/api/users/${userId}/orders`).then(r => r.json()),
fetch(`/api/recommendations/${userId}`).then(r => r.json())
]);

return {
name: profile.name,
recentOrders: orders.slice(0, 5),
topPicks: recommendations.slice(0, 3)
};
}

Parallel with Error Tolerance

Use Promise.allSettled() when some operations can fail without invalidating the whole batch:

async function loadDashboardResilient(userId) {
const results = await Promise.allSettled([
fetch(`/api/users/${userId}`).then(r => r.json()),
fetch(`/api/users/${userId}/orders`).then(r => r.json()),
fetch(`/api/users/${userId}/notifications`).then(r => r.json())
]);

return {
user: results[0].status === "fulfilled" ? results[0].value : null,
orders: results[1].status === "fulfilled" ? results[1].value : [],
notifications: results[2].status === "fulfilled" ? results[2].value : []
};
}

Mixed: Some Sequential, Some Parallel

When some operations depend on others but independent operations can run in parallel:

async function processOrder(orderId) {
// Step 1: Fetch the order (must happen first)
const order = await fetchOrder(orderId);

// Step 2: Fetch user and inventory in parallel (both depend on order, not each other)
const [user, inventory] = await Promise.all([
fetchUser(order.userId),
checkInventory(order.items)
]);

// Step 3: Process payment (depends on user and order)
const payment = await processPayment(user, order.total);

// Step 4: Send email and update inventory in parallel
await Promise.all([
sendConfirmationEmail(user.email, order),
updateInventory(order.items)
]);

return { order, user, payment };
}

Top-Level await (ES Modules)

Traditionally, await could only be used inside async functions. ES2022 introduced top-level await, which lets you use await at the module level in ES modules.

Basic Usage

// config.js (ES module)
const response = await fetch("/api/config");
export const config = await response.json();

console.log("Config loaded:", config.appName);
// main.js (ES module)
import { config } from "./config.js";

// config is guaranteed to be loaded by the time this runs
console.log("Using config:", config.appName);

How It Works

The module that uses top-level await becomes an async module. Any module that imports from it automatically waits for the async module to finish loading before executing:

// database.js
console.log("Connecting to database...");
const connection = await connectToDatabase();
console.log("Connected!");

export { connection };

// app.js
import { connection } from "./database.js";
// This line only executes after database.js fully resolves
console.log("App starting with database:", connection.status);

Use Cases

// Dynamic configuration
export const config = await fetch("/api/config").then(r => r.json());

// Conditional dependency loading
let translator;
if (navigator.language.startsWith("fr")) {
translator = await import("./i18n/french.js");
} else {
translator = await import("./i18n/english.js");
}
export { translator };

// Database seeding / initialization
import { db } from "./database.js";

const hasData = await db.collection("users").countDocuments();
if (hasData === 0) {
await db.collection("users").insertMany(defaultUsers);
console.log("Database seeded with default users");
}

Limitations

<!-- Top-level await requires type="module" -->
<script type="module">
const data = await fetch("/api/data").then(r => r.json());
console.log(data);
</script>

<!-- Does NOT work in regular scripts -->
<script>
const data = await fetch("/api/data"); // SyntaxError!
</script>

Top-level await only works in ES modules (type="module" in browsers, .mjs files or "type": "module" in Node.js package.json).

caution

Top-level await blocks the loading of any module that depends on it. Use it only for initialization that must complete before the module's exports are available. Avoid long-running operations at the top level, as they delay the entire import chain.

await in Loops: Sequential vs. Parallel Pitfall

Using await inside loops is one of the most common sources of performance problems. The behavior depends on the type of loop and whether the operations are independent.

for Loop: Sequential (Often Unintentional)

// SEQUENTIAL: each request waits for the previous one
// If each takes 200ms and there are 10 items: total ≈ 2000ms
async function fetchAllSequential(userIds) {
const users = [];

for (const id of userIds) {
const user = await fetchUser(id); // Waits before starting the next
users.push(user);
}

return users;
}

Each iteration waits for fetchUser to complete before starting the next request. If the requests are independent, this wastes time.

Parallel Alternative: map + Promise.all

// PARALLEL: all requests fire simultaneously
// If each takes 200ms and there are 10 items: total ≈ 200ms
async function fetchAllParallel(userIds) {
const users = await Promise.all(
userIds.map(id => fetchUser(id))
);

return users;
}

All requests start at the same time. Promise.all waits for all of them to complete.

When Sequential Is Correct

Sometimes sequential execution is required because each step depends on the previous one:

// Sequential is CORRECT here: each step depends on the previous
async function processChain(items) {
let result = initialValue;

for (const item of items) {
result = await processWithPrevious(item, result);
// Each iteration needs the result of the previous one
}

return result;
}
// Sequential is CORRECT: rate limiting or ordered operations
async function migrateRecords(records) {
for (const record of records) {
await insertRecord(record); // Must maintain order
await delay(100); // Rate limiting
console.log(`Migrated: ${record.id}`);
}
}

forEach with async: The Broken Pattern

Array.forEach does not wait for async callbacks. It fires all callbacks and returns immediately:

// BROKEN: forEach doesn't await async callbacks
async function processBroken(items) {
items.forEach(async (item) => {
const result = await processItem(item);
console.log(result);
});

console.log("Done!"); // Logs BEFORE any items are processed!
}

// FIXED: use for...of for sequential
async function processSequential(items) {
for (const item of items) {
const result = await processItem(item);
console.log(result);
}

console.log("Done!"); // Logs AFTER all items are processed
}

// FIXED: use map + Promise.all for parallel
async function processParallel(items) {
await Promise.all(
items.map(async (item) => {
const result = await processItem(item);
console.log(result);
})
);

console.log("Done!"); // Logs AFTER all items are processed
}

Controlled Concurrency: Batching

When you have many items but do not want to fire all requests at once (to avoid overwhelming the server):

async function processInBatches(items, batchSize, fn) {
const results = [];

for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(fn));
results.push(...batchResults);
console.log(`Processed batch ${Math.floor(i / batchSize) + 1}`);
}

return results;
}

// Process 100 items, 10 at a time
const users = await processInBatches(
userIds,
10,
id => fetchUser(id)
);

Async Methods in Classes

async works naturally with class methods, providing a clean way to define asynchronous behavior on objects.

Basic Async Methods

class UserService {
#baseUrl;

constructor(baseUrl) {
this.#baseUrl = baseUrl;
}

async getUser(id) {
const response = await fetch(`${this.#baseUrl}/users/${id}`);
if (!response.ok) {
throw new Error(`User ${id} 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 deleteUser(id) {
const response = await fetch(`${this.#baseUrl}/users/${id}`, {
method: "DELETE"
});
if (!response.ok) {
throw new Error(`Failed to delete user ${id}`);
}
}
}

const service = new UserService("https://api.example.com");
const user = await service.getUser(42);

Static Async Methods

class Database {
#connection;

constructor(connection) {
this.#connection = connection;
}

// Static async factory method
static async connect(connectionString) {
const connection = await establishConnection(connectionString);
await connection.ping(); // Verify the connection works
return new Database(connection);
}

async query(sql, params) {
return this.#connection.execute(sql, params);
}

async close() {
await this.#connection.end();
}
}

// Usage
const db = await Database.connect("postgres://localhost/mydb");
const users = await db.query("SELECT * FROM users WHERE active = $1", [true]);
await db.close();

Async Getters: Not Supported Directly

JavaScript does not support async get. Getters must return synchronously. If you need async initialization, use a different pattern:

// DOES NOT WORK
class Broken {
async get data() { // SyntaxError!
return await fetchData();
}
}

// WORKAROUND 1: Use a method instead of a getter
class UserProfile {
async getData() {
if (!this._data) {
this._data = await fetch("/api/profile").then(r => r.json());
}
return this._data;
}
}

// WORKAROUND 2: Initialize in constructor, expose as regular getter
class Config {
#settings;

constructor(settings) {
this.#settings = settings;
}

get settings() {
return this.#settings; // Synchronous access
}

static async load() {
const settings = await fetch("/api/config").then(r => r.json());
return new Config(settings);
}
}

const config = await Config.load();
console.log(config.settings); // Sync access after async initialization

Converting Promise Chains to Async/Await

Most promise chains translate naturally to async/await. Here are common patterns with their equivalents.

Simple Chain

// Promise chain
function getUser(id) {
return fetch(`/api/users/${id}`)
.then(response => response.json())
.then(user => {
console.log("Got user:", user.name);
return user;
})
.catch(err => {
console.error("Failed:", err.message);
return null;
});
}

// Async/await
async function getUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
console.log("Got user:", user.name);
return user;
} catch (err) {
console.error("Failed:", err.message);
return null;
}
}

Chained Operations

// Promise chain
function processOrder(orderId) {
return fetchOrder(orderId)
.then(order => fetchUser(order.userId).then(user => ({ order, user })))
.then(({ order, user }) => processPayment(user, order.total))
.then(payment => sendConfirmation(payment))
.catch(err => handleError(err));
}

// Async/await (much clearer)
async function processOrder(orderId) {
try {
const order = await fetchOrder(orderId);
const user = await fetchUser(order.userId);
const payment = await processPayment(user, order.total);
const confirmation = await sendConfirmation(payment);
return confirmation;
} catch (err) {
handleError(err);
}
}

Conditional Logic

// Promise chain (awkward with conditions)
function loadContent(type) {
return fetch(`/api/content/${type}`)
.then(response => {
if (response.status === 404) {
return fetch("/api/content/default").then(r => r.json());
}
return response.json();
})
.then(content => {
if (content.needsAuth) {
return authenticate().then(() => content);
}
return content;
});
}

// Async/await (reads like synchronous code)
async function loadContent(type) {
let response = await fetch(`/api/content/${type}`);

if (response.status === 404) {
response = await fetch("/api/content/default");
}

const content = await response.json();

if (content.needsAuth) {
await authenticate();
}

return content;
}

Keeping .then() When It Is Cleaner

Sometimes a simple .then() on a single promise is more concise than a full async function. You do not have to convert everything:

// This is fine (simple transformation)
const data = await fetch("/api/data").then(r => r.json());

// No need to write:
const response = await fetch("/api/data");
const data = await response.json();

// Both are valid. Use whichever reads better in context.

Common Mistakes

Mistake 1: Unnecessary await

await on a value that is not a promise, or await before returning from an async function, is often unnecessary:

// UNNECESSARY (return already wraps in a promise)
async function getUser(id) {
const user = await fetchUser(id);
return await processUser(user); // The outer await is redundant
}

// CLEANER
async function getUser(id) {
const user = await fetchUser(id);
return processUser(user); // async function wraps the return in a promise anyway
}

However, there is one case where return await matters: inside try...catch:

// BUG: error from processUser is NOT caught
async function getUser(id) {
try {
const user = await fetchUser(id);
return processUser(user); // If this rejects, catch doesn't see it
} catch (err) {
console.error("Failed:", err);
return null;
}
}

// CORRECT: await ensures the error is caught
async function getUser(id) {
try {
const user = await fetchUser(id);
return await processUser(user); // Now rejection is caught
} catch (err) {
console.error("Failed:", err);
return null;
}
}

Without await in the try block, return processUser(user) returns the promise directly. If that promise rejects, the rejection happens after the try...catch has already exited, so catch never runs.

Mistake 2: Missing Error Handling

Calling an async function without handling its errors creates unhandled rejections:

// BAD: no error handling anywhere
async function init() {
const config = await loadConfig(); // Could throw
const db = await connectDatabase(); // Could throw
await startServer(config, db); // Could throw
}

init(); // If anything fails: UnhandledPromiseRejection

// GOOD: handle at the call site
init().catch(err => {
console.error("Startup failed:", err);
process.exit(1);
});

// OR handle inside the function
async function init() {
try {
const config = await loadConfig();
const db = await connectDatabase();
await startServer(config, db);
} catch (err) {
console.error("Startup failed:", err);
process.exit(1);
}
}

Mistake 3: Sequential await in Loops When Parallel Is Better

// SLOW: 10 sequential requests, one at a time
async function loadUsers(ids) {
const users = [];
for (const id of ids) {
users.push(await fetchUser(id)); // Each waits for the previous
}
return users;
}

// FAST: 10 parallel requests, all at once
async function loadUsers(ids) {
return Promise.all(ids.map(id => fetchUser(id)));
}

If each request takes 200ms and you have 10 IDs:

  • Sequential: 10 x 200ms = 2000ms
  • Parallel: max(200ms) = 200ms

Mistake 4: forEach with async Callbacks

// BROKEN: forEach does not wait for async callbacks
async function processItems(items) {
items.forEach(async item => {
await saveToDatabase(item); // These all fire at once, uncontrolled
});
console.log("Done!"); // Lies (nothing is done yet)
}

// CORRECT: for...of for sequential
async function processItems(items) {
for (const item of items) {
await saveToDatabase(item);
}
console.log("Done!"); // Actually done
}

// CORRECT: Promise.all for parallel
async function processItems(items) {
await Promise.all(items.map(item => saveToDatabase(item)));
console.log("Done!"); // Actually done
}

Mistake 5: Creating Promises but Not Awaiting Them

// BUG: promise created but not awaited
async function updateUser(id, data) {
const user = await fetchUser(id);

// This runs in the background. Errors are unhandled
// The function returns before logging is complete
logActivity(user.id, "update"); // Returns a promise, but not awaited

return saveUser({ ...user, ...data });
}

// FIXED: await if you need to wait for it
async function updateUser(id, data) {
const user = await fetchUser(id);
await logActivity(user.id, "update");
return saveUser({ ...user, ...data });
}

// ALSO FINE: explicitly fire-and-forget with error handling
async function updateUser(id, data) {
const user = await fetchUser(id);

// Intentional fire-and-forget with error handling
logActivity(user.id, "update").catch(err => {
console.warn("Logging failed (non-critical):", err.message);
});

return saveUser({ ...user, ...data });
}

Mistake 6: Awaiting in a map Without Promise.all

// BROKEN: map returns an array of promises, not an array of results
async function getNames(ids) {
const names = ids.map(async id => {
const user = await fetchUser(id);
return user.name;
});

console.log(names); // [Promise, Promise, Promise] (not what you wanted!)
}

// FIXED: wrap map in Promise.all
async function getNames(ids) {
const names = await Promise.all(
ids.map(async id => {
const user = await fetchUser(id);
return user.name;
})
);

console.log(names); // ["Alice", "Bob", "Charlie"] (actual values)
}

Summary

async/await makes asynchronous JavaScript read like synchronous code while retaining all the power of promises underneath. It simplifies sequential operations, error handling, and conditional logic, but requires understanding its interaction with parallel execution and loops.

ConceptKey Point
async functionAlways returns a promise. Enables await inside.
awaitPauses the async function until the promise settles. Returns the fulfilled value or throws the rejection reason.
Error handlingUse try...catch around await. Works exactly like synchronous error handling.
Promise.all + awaitRun independent operations in parallel. Destructure the results.
Top-level awaitAvailable in ES modules only. Blocks importing modules until resolved.
for...of + awaitSequential execution. Each iteration waits for the previous.
map + Promise.allParallel execution. All operations start immediately.
forEach + asyncBroken. forEach does not await async callbacks. Use for...of or map + Promise.all.
Async class methodsWork naturally. Use static async methods for factory patterns.
return awaitRedundant normally, but required inside try...catch to catch rejections.

Key rules to remember:

  • await pauses only the containing function, not the entire thread
  • Always handle errors from async functions (either try...catch inside or .catch() outside)
  • Use Promise.all() for independent operations that can run in parallel
  • Never use forEach with async callbacks. Use for...of or Promise.all(arr.map(...)).
  • return await is needed inside try...catch to properly catch rejections
  • Top-level await works only in ES modules
  • Async functions without await inside them are perfectly valid but slightly misleading to readers

Table of Contents