Understanding Promises
A Promise represents the eventual result of an asynchronous operation. It can be in one of three states: pending, fulfilled, or rejected.
// Creating a Promise
const fetchData = new Promise((resolve, reject) => {
const success = Math.random() > 0.5;
setTimeout(() => {
if (success) {
resolve({ data: "Hello!", status: 200 });
} else {
reject(new Error("Network error"));
}
}, 1000);
});
// Consuming a Promise with .then/.catch
fetchData
.then(result => {
console.log("Success:", result.data);
return result.status; // Pass value to next .then
})
.then(status => {
console.log("Status:", status);
})
.catch(error => {
console.error("Error:", error.message);
})
.finally(() => {
console.log("Always runs");
});
// Promise.all - wait for multiple promises
const promises = [
fetch('/api/users'),
fetch('/api/posts'),
fetch('/api/comments'),
];
Promise.all(promises)
.then(responses => Promise.all(responses.map(r => r.json())))
.then(([users, posts, comments]) => {
console.log({ users, posts, comments });
});
Async/Await Syntax
Async/await is syntactic sugar over Promises that makes asynchronous code look and behave more like synchronous code. It's much easier to read and debug.
// async function always returns a Promise
async function fetchUser(id) {
// await pauses execution until Promise resolves
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const user = await response.json();
return user;
}
// Error handling with try/catch
async function getUserPosts(userId) {
try {
const user = await fetchUser(userId);
const postsResponse = await fetch(`/api/posts?userId=${userId}`);
const posts = await postsResponse.json();
return { user, posts };
} catch (error) {
console.error("Failed to fetch:", error.message);
return null;
}
}
// Running multiple async operations in parallel
async function loadDashboard() {
// Sequential (slow) - don't do this
// const users = await fetchUsers();
// const stats = await fetchStats();
// Parallel (fast) - do this instead
const [users, stats] = await Promise.all([
fetchUsers(),
fetchStats(),
]);
return { users, stats };
}
// Top-level await (in modules)
const config = await fetch('/api/config').then(r => r.json());
Error Handling Patterns
Proper error handling in async code prevents silent failures and improves user experience. Here are proven patterns for robust error handling.
// Pattern 1: Try/catch
async function withTryCatch() {
try {
const data = await riskyOperation();
return { data, error: null };
} catch (error) {
return { data: null, error };
}
}
// Pattern 2: Result tuple (inspired by Go)
async function safeAsync(promise) {
try {
const data = await promise;
return [null, data];
} catch (error) {
return [error, null];
}
}
// Usage of Result tuple
async function example() {
const [error, data] = await safeAsync(fetchUser(1));
if (error) {
console.error("Failed:", error.message);
return;
}
console.log("User:", data);
}
// Pattern 3: AbortController for cancellable requests
async function fetchWithTimeout(url, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw error;
}
}
Always handle Promise rejections. Unhandled rejections can crash Node.js applications and cause silent failures in browsers.