Async/await is modern JavaScript syntax that makes asynchronous code look and behave more like synchronous code. Built on top of Promises, async/await provides a cleaner way to handle asynchronous operations, making code more readable and maintainable.
In this tutorial, we'll explore async/await syntax, error handling, patterns, and best practices for writing effective asynchronous JavaScript code.
Understanding Async/Await
Async/await consists of two keywords: async for declaring asynchronous functions and await for pausing execution until a Promise resolves.
Basic Syntax
// async function declaration
async function fetchData() {
return 'Data fetched';
}
// async function always returns a Promise
const result = fetchData();
console.log(result); // Promise<resolved>
// await keyword (only in async functions)
async function example() {
const data = await fetchData(); // Wait for Promise to resolve
console.log(data); // "Data fetched"
}
example();
Comparison with Promises
// Promise-based approach
function fetchUserData() {
return fetch('/api/user')
.then(response => response.json())
.then(user => {
console.log('User:', user);
return user.id;
})
.then(userId => {
return fetch(`/api/posts/${userId}`);
})
.then(response => response.json())
.then(posts => {
console.log('Posts:', posts);
return posts;
})
.catch(error => {
console.error('Error:', error);
});
}
// async/await approach
async function fetchUserDataAsync() {
try {
const response = await fetch('/api/user');
const user = await response.json();
console.log('User:', user);
const postsResponse = await fetch(`/api/posts/${user.id}`);
const posts = await postsResponse.json();
console.log('Posts:', posts);
return posts;
} catch (error) {
console.error('Error:', error);
}
}
Arrow Functions with async
// async arrow function
const getData = async () => {
const response = await fetch('/api/data');
return response.json();
};
// async arrow function with parameters
const getUserById = async (id) => {
const response = await fetch(`/api/users/${id}`);
return response.json();
};
// Immediately invoked async function
(async () => {
const data = await getData();
console.log(data);
})();
Error Handling
Proper error handling is crucial in async/await code using try/catch blocks.
Try/Catch Blocks
// Basic error handling
async function fetchData() {
try {
const response = await fetch('/api/data');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Failed to fetch data:', error);
return null; // Return fallback value
}
}
// Multiple await operations with error handling
async function processUserData(userId) {
try {
const user = await fetchUser(userId);
const profile = await fetchProfile(user.profileId);
const settings = await fetchSettings(user.id);
return {
user,
profile,
settings
};
} catch (error) {
console.error('Error processing user data:', error);
throw error; // Re-throw if you want caller to handle
}
}
Granular Error Handling
// Handle different errors differently
async function fetchUserData(userId) {
try {
const user = await fetchUser(userId);
return user;
} catch (error) {
if (error instanceof NetworkError) {
console.log('Network error, retrying...');
return fetchUserData(userId); // Retry
} else if (error instanceof AuthenticationError) {
console.log('Authentication failed, redirecting to login');
redirectToLogin();
return null;
} else {
console.error('Unexpected error:', error);
throw error;
}
}
}
// Try/catch for individual operations
async function processMultipleItems(items) {
const results = [];
for (const item of items) {
try {
const result = await processItem(item);
results.push(result);
} catch (error) {
console.error(`Error processing item ${item.id}:`, error);
results.push({ id: item.id, error: error.message });
}
}
return results;
}
Finally Block
// Finally block for cleanup
async function processWithCleanup() {
let connection;
try {
connection = await createDatabaseConnection();
const data = await fetchData(connection);
const processed = await processData(data);
return processed;
} catch (error) {
console.error('Processing failed:', error);
throw error;
} finally {
if (connection) {
await connection.close();
console.log('Connection closed');
}
}
}
// Utility for cleanup
async function withCleanup(resource, callback) {
try {
return await callback(resource);
} finally {
await resource.cleanup();
}
}
Parallel Execution
Running multiple async operations in parallel for better performance.
Promise.all
// Parallel execution with Promise.all
async function fetchUserDataAndPosts(userId) {
try {
const [user, posts, comments] = await Promise.all([
fetchUser(userId),
fetchUserPosts(userId),
fetchUserComments(userId)
]);
return { user, posts, comments };
} catch (error) {
console.error('Failed to fetch user data:', error);
throw error;
}
}
// Parallel execution with mapping
async function fetchMultipleUsers(userIds) {
const userPromises = userIds.map(id => fetchUser(id));
const users = await Promise.all(userPromises);
return users;
}
// Handle partial failures
async function fetchWithFallbacks(userIds) {
const userPromises = userIds.map(async (id) => {
try {
return await fetchUser(id);
} catch (error) {
console.error(`Failed to fetch user ${id}:`, error);
return { id, error: error.message };
}
});
return await Promise.all(userPromises);
}
Promise.allSettled
// Handle all results, even failures
async function fetchAllUserStatuses(userIds) {
const promises = userIds.map(id => fetchUserStatus(id));
const results = await Promise.allSettled(promises);
const successful = results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
const failed = results
.filter(result => result.status === 'rejected')
.map(result => result.reason);
return { successful, failed };
}
// Process results based on status
async function processBatch(items) {
const promises = items.map(item => processItem(item));
const results = await Promise.allSettled(promises);
return results.map((result, index) => ({
item: items[index],
status: result.status,
value: result.status === 'fulfilled' ? result.value : null,
error: result.status === 'rejected' ? result.reason : null
}));
}
Promise.race
// Race between multiple operations
async function fetchWithTimeout(url, timeout = 5000) {
const fetchPromise = fetch(url);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Request timeout')), timeout);
});
try {
const response = await Promise.race([fetchPromise, timeoutPromise]);
return response;
} catch (error) {
if (error.message === 'Request timeout') {
console.log('Request timed out');
}
throw error;
}
}
// Race multiple sources
async function fetchFromMultipleSources(sources) {
const promises = sources.map(source => fetch(source));
try {
const response = await Promise.race(promises);
return response;
} catch (error) {
console.error('All sources failed:', error);
throw error;
}
}
Async Patterns
Common patterns and techniques for working with async/await.
Sequential Processing
// Process items sequentially
async function processSequentially(items) {
const results = [];
for (const item of items) {
const result = await processItem(item);
results.push(result);
}
return results;
}
// Process with rate limiting
async function processWithRateLimit(items, delay = 100) {
const results = [];
for (const item of items) {
const result = await processItem(item);
results.push(result);
await new Promise(resolve => setTimeout(resolve, delay));
}
return results;
}
// Sequential processing with error recovery
async function processWithRetry(items, maxRetries = 3) {
const results = [];
for (const item of items) {
let retries = 0;
let result = null;
let error = null;
while (retries < maxRetries && !result) {
try {
result = await processItem(item);
} catch (err) {
error = err;
retries++;
if (retries < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 1000 * retries));
}
}
}
results.push(result || { error, retries });
}
return results;
}
Bounded Parallelism
// Process items with limited concurrency
async function processWithConcurrency(items, concurrency = 3) {
const results = [];
const executing = [];
for (const item of items) {
const promise = processItem(item).then(result => {
results.push(result);
return result;
});
executing.push(promise);
if (executing.length >= concurrency) {
await Promise.race(executing);
executing.splice(executing.findIndex(p => p === promise), 1);
}
}
await Promise.all(executing);
return results;
}
// Worker pool pattern
class AsyncPool {
constructor(concurrency, processor) {
this.concurrency = concurrency;
this.processor = processor;
this.running = 0;
this.queue = [];
}
async process(item) {
return new Promise((resolve, reject) => {
this.queue.push({ item, resolve, reject });
this.processQueue();
});
}
async processQueue() {
if (this.running >= this.concurrency || this.queue.length === 0) {
return;
}
this.running++;
const { item, resolve, reject } = this.queue.shift();
try {
const result = await this.processor(item);
resolve(result);
} catch (error) {
reject(error);
} finally {
this.running--;
this.processQueue();
}
}
}
Caching Patterns
// Simple cache with async operations
class AsyncCache {
constructor() {
this.cache = new Map();
this.pending = new Map();
}
async get(key, fetchFn) {
// Return cached value if available
if (this.cache.has(key)) {
return this.cache.get(key);
}
// Return pending promise if already fetching
if (this.pending.has(key)) {
return this.pending.get(key);
}
// Start fetching
const promise = fetchFn(key);
this.pending.set(key, promise);
try {
const result = await promise;
this.cache.set(key, result);
return result;
} finally {
this.pending.delete(key);
}
}
}
// Usage
const cache = new AsyncCache();
async function getUserData(userId) {
return cache.get(userId, async (id) => {
const response = await fetch(`/api/users/${id}`);
return response.json();
});
}
// TTL cache
class TTLCache {
constructor(ttl = 60000) { // Default 1 minute
this.cache = new Map();
this.ttl = ttl;
}
async get(key, fetchFn) {
const cached = this.cache.get(key);
if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.value;
}
const value = await fetchFn(key);
this.cache.set(key, {
value,
timestamp: Date.now()
});
return value;
}
}
Async Generators
Combining generators with async for powerful data streaming patterns.
Async Generator Functions
// Async generator function
async function* fetchPages(url) {
let page = 1;
while (true) {
const response = await fetch(`${url}?page=${page}`);
const data = await response.json();
if (data.items.length === 0) break;
yield data.items;
page++;
}
}
// Consume async generator
async function processAllPages(url) {
const generator = fetchPages(url);
for await (const items of generator) {
console.log(`Processing ${items.length} items`);
await processItems(items);
}
}
// Async generator for pagination
async function* paginate(apiUrl, pageSize = 10) {
let offset = 0;
while (true) {
const response = await fetch(`${apiUrl}?limit=${pageSize}&offset=${offset}`);
const data = await response.json();
if (data.items.length === 0) break;
yield* data.items; // Yield each item individually
offset += pageSize;
}
}
Streaming Data
// Stream data from API
async function* streamData(url) {
const response = await fetch(url);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let done = false;
while (!done) {
const { value, done: readerDone } = await reader.read();
done = readerDone;
if (value) {
const chunk = decoder.decode(value, { stream: true });
yield chunk;
}
}
}
// Process streaming data
async function processStream(url) {
for await (const chunk of streamData(url)) {
console.log('Received chunk:', chunk);
await processChunk(chunk);
}
}
// Rate-limited stream
async function* rateLimitedStream(generator, delay = 1000) {
for await (const item of generator) {
yield item;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
Top-Level Await
Using await at the top level of modules (ES2022).
Module-Level Await
// In ES modules (not in scripts)
// config.mjs
export const config = await fetchConfig();
export const database = await connectDatabase(config);
// Use config and database in the module
export async function initializeApp() {
await database.migrate();
console.log('App initialized');
}
// Import in another module
import { config, database, initializeApp } from './config.mjs';
console.log('Config loaded:', config);
await initializeApp();
Dynamic Imports with Await
// Dynamic import with top-level await
const { default: lodash } = await import('lodash');
const utils = await import('./utils.js');
// Conditional module loading
const heavyModule = await import('./heavy-module.js');
export { heavyModule };
// Lazy loading
let heavyLibrary = null;
export async function getHeavyLibrary() {
if (!heavyLibrary) {
heavyLibrary = await import('./heavy-library.js');
}
return heavyLibrary;
}
Performance Considerations
Optimizing async/await code for better performance.
Avoid Unnecessary Await
// Bad: Sequential when parallel is possible
async function badExample() {
const user = await fetchUser(1);
const posts = await fetchUserPosts(1);
const comments = await fetchUserComments(1);
return { user, posts, comments };
}
// Good: Parallel execution
async function goodExample() {
const [user, posts, comments] = await Promise.all([
fetchUser(1),
fetchUserPosts(1),
fetchUserComments(1)
]);
return { user, posts, comments };
}
// Bad: Unnecessary await in return
async function badReturn() {
const data = await fetchData();
return data; // Unnecessary await
}
// Good: Direct return
async function goodReturn() {
return await fetchData(); // or just return fetchData()
}
Memory Management
// Process large datasets in chunks
async function processLargeDataset(dataset, chunkSize = 1000) {
for (let i = 0; i < dataset.length; i += chunkSize) {
const chunk = dataset.slice(i, i + chunkSize);
await processChunk(chunk);
// Allow garbage collection
if (i % (chunkSize * 10) === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
// Stream processing to avoid loading all data
async function processStreamData(url) {
const response = await fetch(url);
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
await processChunk(value);
// Process immediately, don't accumulate
}
}
Best Practices
Guidelines for writing clean and maintainable async/await code.
Error Handling
// Always handle errors
async function robustFetch(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error(`Failed to fetch ${url}:`, error);
throw error; // Re-throw for caller to handle
}
}
// Create reusable error handling utilities
const withErrorHandling = (fn) => {
return async (...args) => {
try {
return await fn(...args);
} catch (error) {
console.error(`Error in ${fn.name}:`, error);
throw error;
}
};
};
const safeFetch = withErrorHandling(fetchData);
Code Organization
// Separate async logic from business logic
async function fetchUserData(userId) {
// Only data fetching logic
const response = await fetch(`/api/users/${userId}`);
return response.json();
}
function processUserData(userData) {
// Only business logic
return {
fullName: `${userData.firstName} ${userData.lastName}`,
displayName: userData.firstName,
age: calculateAge(userData.birthDate)
};
}
// Combine in service layer
async function getUserProfile(userId) {
const userData = await fetchUserData(userId);
return processUserData(userData);
}
// Use dependency injection for testability
async function createUserService(apiClient) {
return {
async getUser(id) {
return apiClient.get(`/users/${id}`);
},
async updateUser(id, data) {
return apiClient.put(`/users/${id}`, data);
}
};
}
Testing Async Code
// Test async functions properly
describe('User Service', () => {
it('should fetch user data', async () => {
const userData = await userService.getUser(1);
expect(userData).toBeDefined();
expect(userData.id).toBe(1);
});
it('should handle errors', async () => {
await expect(userService.getUser(999))
.rejects.toThrow('User not found');
});
it('should timeout if request takes too long', async () => {
await expect(
Promise.race([
userService.getUser(1),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 100)
)
])
).rejects.toThrow('Timeout');
});
});
Summary
Key Takeaways
- async/await makes asynchronous code look synchronous
- Always use try/catch for error handling
- Use Promise.all for parallel execution
- Avoid unnecessary sequential operations
- Top-level await works in ES modules
Best Practices
- Handle errors at appropriate levels
- Use parallel execution when possible
- Separate data fetching from business logic
- Implement proper timeout handling
- Test async code thoroughly
Common Pitfalls
- Forgetting to handle Promise rejections
- Using await in loops when parallel is better
- Mixing callbacks and async/await
- Not handling timeouts properly
- Creating memory leaks with unresolved Promises