Skip to main content

async/await

async/await is syntactic sugar that lets you write Promise-based code in a more readable, synchronous style. It was introduced in ES2017.


Basic Syntax

// async function declaration
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
return user; // Automatically wrapped in Promise.resolve(user)
}

// async functions always return a Promise
const result = fetchUser(1);
console.log(result); // Promise { <pending> }

// Arrow function
const getUser = async (id) => {
const response = await fetch(`/api/users/${id}`);
return response.json();
};

// Object method
const api = {
async getUser(id) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
};

// Class method
class UserService {
async findById(id) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
}

Syntactic Sugar Over Promises

async/await and Promises are completely equivalent:

// Promise style
function fetchUserPromise(id) {
return fetch(`/api/users/${id}`)
.then(response => {
if (!response.ok) throw new Error('HTTP error: ' + response.status);
return response.json();
})
.then(user => {
console.log(user.name);
return user;
})
.catch(err => {
console.error('Error:', err);
throw err;
});
}

// async/await style (identical behavior)
async function fetchUserAsync(id) {
const response = await fetch(`/api/users/${id}`);

if (!response.ok) throw new Error('HTTP error: ' + response.status);

const user = await response.json();
console.log(user.name);
return user;
}

// await also accepts non-Promise values (returns them as-is)
async function example() {
const value = await 42; // Just 42
const str = await 'hello'; // Just 'hello'
console.log(value, str); // 42 hello
}

Error Handling: try/catch

// Handling errors with try/catch
async function fetchWithErrorHandling(id) {
try {
const response = await fetch(`/api/users/${id}`);

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

const user = await response.json();
return user;

} catch (err) {
if (err.name === 'TypeError') {
console.error('Network connection failed:', err.message);
} else if (err.message.startsWith('HTTP 404')) {
console.error('User not found');
} else {
console.error('Unexpected error:', err);
}
return null; // Return default value
} finally {
console.log('Request complete (regardless of success or failure)');
}
}

// Propagating errors upward
async function processUser(id) {
const user = await fetchUser(id); // Errors automatically propagate
return transformUser(user);
}

async function main() {
try {
const user = await processUser(1);
console.log(user);
} catch (err) {
console.error('Top-level error handler:', err);
}
}

await Can Only Be Used Inside async Functions

// await cannot be used in a regular function
function normalFunction() {
// const data = await fetch('/api/data'); // SyntaxError!
}

// Can only be used inside an async function
async function asyncFunction() {
const data = await fetch('/api/data'); // OK
}

// Using async in callbacks
const buttons = document.querySelectorAll('button');
buttons.forEach(async (button) => { // Each callback is an async function
button.addEventListener('click', async (event) => {
const data = await fetchData();
console.log(data);
});
});

// Be careful with async callbacks in Array methods
const ids = [1, 2, 3];

// Wrong: forEach does not wait for async
ids.forEach(async (id) => {
const user = await fetchUser(id);
console.log(user); // Order not guaranteed
});

// Correct: use for...of
for (const id of ids) {
const user = await fetchUser(id);
console.log(user); // Runs in order
}

Top-level await (ES2022)

Since ES2022, await can be used at the top level in ES modules.

// config.mjs - using await at the module top level
const config = await fetch('/api/config').then(r => r.json());

export const apiUrl = config.apiUrl;
export const maxRetries = config.maxRetries;

// When this module is imported, it is available after config loading completes
// main.mjs
import { apiUrl, maxRetries } from './config.mjs';
// Imported after the top-level await in config.mjs completes

console.log(apiUrl); // Already loaded value
// Database connection initialization
// db.mjs
import { MongoClient } from 'mongodb';

const client = new MongoClient(process.env.MONGODB_URI);
await client.connect(); // Top-level await

export const db = client.db('myapp');
export const usersCollection = db.collection('users');

Sequential vs Parallel Execution

This is the most important performance concept in async/await.

// Sequential execution (each await runs after the previous one completes)
async function sequential() {
console.time('sequential');

const user = await fetchUser(1); // Wait 100ms
const posts = await fetchPosts(1); // Wait 100ms
const comments = await fetchComments(1); // Wait 100ms

console.timeEnd('sequential'); // ~300ms
return { user, posts, comments };
}

// Parallel execution (using Promise.all)
async function parallel() {
console.time('parallel');

const [user, posts, comments] = await Promise.all([
fetchUser(1), // Start simultaneously
fetchPosts(1), // Start simultaneously
fetchComments(1) // Start simultaneously
]);

console.timeEnd('parallel'); // ~100ms (bounded by the slowest)
return { user, posts, comments };
}

// When dependencies exist: some sequential, some parallel
async function mixed() {
const user = await fetchUser(1); // Must come first

// user.id is required, but these two requests can run in parallel
const [posts, followers] = await Promise.all([
fetchPosts(user.id),
fetchFollowers(user.id)
]);

return { user, posts, followers };
}

Promise.all + await Combined Pattern

// Basic pattern
async function loadDashboard(userId) {
const [profile, stats, activity] = await Promise.all([
getProfile(userId),
getStats(userId),
getActivity(userId)
]);

return { profile, stats, activity };
}

// With error handling
async function loadDashboardSafe(userId) {
const results = await Promise.allSettled([
getProfile(userId),
getStats(userId),
getActivity(userId)
]);

return {
profile: results[0].value ?? null,
stats: results[1].value ?? {},
activity: results[2].value ?? []
};
}

// Dynamic parallel processing
async function processAll(items) {
// Process all items simultaneously
const promises = items.map(item => processItem(item));
return Promise.all(promises);
}

// Batch processing (with concurrency limit)
async function processBatch(items, batchSize = 5) {
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(processItem));
results.push(...batchResults);
console.log(`Batch ${Math.floor(i/batchSize) + 1} complete`);
}

return results;
}

Error Handling Patterns: try/catch vs .catch()

// Approach 1: try/catch (async/await style)
async function withTryCatch() {
try {
const data = await riskyOperation();
return processData(data);
} catch (err) {
handleError(err);
return defaultValue;
}
}

// Approach 2: .catch() (Promise style)
async function withCatch() {
const data = await riskyOperation().catch(err => {
handleError(err);
return null;
});

if (!data) return defaultValue;
return processData(data);
}

// Approach 3: treating errors as values (Go language style)
async function withErrorAsValue() {
const [err, data] = await riskyOperation()
.then(data => [null, data])
.catch(err => [err, null]);

if (err) {
handleError(err);
return defaultValue;
}

return processData(data);
}

// Helper function
function to(promise) {
return promise.then(data => [null, data]).catch(err => [err, null]);
}

async function example() {
const [err, user] = await to(fetchUser(1));
if (err) return console.error(err);

const [err2, posts] = await to(fetchPosts(user.id));
if (err2) return console.error(err2);

console.log(user, posts);
}

Practical Example: API Client

class ApiClient {
#baseUrl;
#defaultHeaders;
#timeout;

constructor(baseUrl, options = {}) {
this.#baseUrl = baseUrl;
this.#defaultHeaders = {
'Content-Type': 'application/json',
...options.headers
};
this.#timeout = options.timeout ?? 10000;
}

async #request(method, path, options = {}) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.#timeout);

try {
const response = await fetch(`${this.#baseUrl}${path}`, {
method,
headers: { ...this.#defaultHeaders, ...options.headers },
body: options.body ? JSON.stringify(options.body) : undefined,
signal: controller.signal
});

if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
throw new ApiError(
errorBody.message ?? `HTTP ${response.status}`,
response.status,
errorBody
);
}

if (response.status === 204) return null;
return response.json();

} catch (err) {
if (err.name === 'AbortError') {
throw new ApiError('Request timed out', 408);
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}

async get(path, options) {
return this.#request('GET', path, options);
}

async post(path, body, options) {
return this.#request('POST', path, { ...options, body });
}

async put(path, body, options) {
return this.#request('PUT', path, { ...options, body });
}

async delete(path, options) {
return this.#request('DELETE', path, options);
}
}

class ApiError extends Error {
constructor(message, status, data = {}) {
super(message);
this.name = 'ApiError';
this.status = status;
this.data = data;
}
}

// Usage example
const api = new ApiClient('https://api.example.com', {
headers: { Authorization: `Bearer ${token}` },
timeout: 5000
});

async function createPost(title, content) {
try {
const post = await api.post('/posts', { title, content });
console.log('Created:', post.id);
return post;
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
await refreshToken();
return createPost(title, content); // Retry
}
throw err;
}
}

Retry Logic

// Exponential backoff retry
async function withRetry(fn, options = {}) {
const {
attempts = 3,
baseDelay = 1000,
maxDelay = 30000,
shouldRetry = (err) => true
} = options;

let lastError;

for (let attempt = 0; attempt < attempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;

if (attempt === attempts - 1 || !shouldRetry(err)) {
throw err;
}

const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
const jitter = Math.random() * 0.3 * delay; // ±30% jitter
console.log(`Retry ${attempt + 1}/${attempts}, waiting ${Math.round(delay + jitter)}ms...`);

await new Promise(resolve => setTimeout(resolve, delay + jitter));
}
}

throw lastError;
}

// Usage
const data = await withRetry(
() => fetch('/api/unstable').then(r => r.json()),
{
attempts: 5,
baseDelay: 500,
shouldRetry: (err) => err.status !== 404 // Don't retry on 404
}
);

Pro Tips

1. Return values of async functions

async function examples() {
// All return a Promise
return 42; // Promise.resolve(42)
return Promise.resolve(42); // Already a Promise (auto-flattened)
throw new Error('failed'); // Promise.reject(new Error('failed'))
}

2. Debugging when you forget await

// Common mistake
async function buggy() {
const data = fetchData(); // No await!
console.log(data.name); // TypeError: data.name is undefined
// ↑ data is a Promise object
}

// Correct async function
async function correct() {
const data = await fetchData();
console.log(data.name); // Correct
}

3. Parallelism mistake

// Bug: without creating Promises first, execution is sequential
async function buggyParallel() {
// This is also sequential! await is attached immediately
const p1 = await fetchA(); // Waits for completion
const p2 = await fetchB(); // Starts after p1 completes
}

// Correct parallel execution
async function trueParallel() {
const p1 = fetchA(); // Starts immediately (no await)
const p2 = fetchB(); // Starts immediately

const [a, b] = await Promise.all([p1, p2]); // Wait for both to complete
}