Async Patterns in Modern JavaScript
From callbacks to async/await—understanding JavaScript's evolution and mastering concurrent operations.
JavaScript's approach to asynchronous programming has evolved dramatically. Let's trace that journey and learn the patterns that make modern async code readable and maintainable.
The Callback Era
In the beginning, there were callbacks:
function fetchUser(id, callback) {
setTimeout(() => {
callback(null, { id, name: 'Alice' });
}, 100);
}
function fetchPosts(userId, callback) {
setTimeout(() => {
callback(null, [
{ id: 1, title: 'Hello World' },
{ id: 2, title: 'Async is Hard' }
]);
}, 100);
}
// The pyramid of doom
fetchUser(1, (err, user) => {
if (err) return console.error(err);
fetchPosts(user.id, (err, posts) => {
if (err) return console.error(err);
console.log(user.name, posts);
});
});This works, but nesting quickly becomes unmanageable. Error handling is scattered and easy to forget.
Promises: A Better Way
Promises flatten the callback pyramid:
function fetchUser(id) {
return new Promise((resolve) => {
setTimeout(() => resolve({ id, name: 'Alice' }), 100);
});
}
function fetchPosts(userId) {
return new Promise((resolve) => {
setTimeout(() => resolve([
{ id: 1, title: 'Hello World' },
{ id: 2, title: 'Async is Hard' }
]), 100);
});
}
// Chained promises
fetchUser(1)
.then(user => fetchPosts(user.id).then(posts => ({ user, posts })))
.then(({ user, posts }) => console.log(user.name, posts))
.catch(err => console.error(err));Better, but still not as readable as synchronous code.
Async/Await: The Modern Standard
Async/await makes asynchronous code read like synchronous code:
async function getUserWithPosts(userId) {
try {
const user = await fetchUser(userId);
const posts = await fetchPosts(user.id);
return { user, posts };
} catch (error) {
console.error('Failed to fetch:', error);
throw error;
}
}
// Usage
const data = await getUserWithPosts(1);
console.log(data.user.name, data.posts);The mental model is straightforward: each await pauses execution until the promise resolves.
Advanced Patterns
Parallel Execution
Don't await sequentially when you can run in parallel:
// ❌ Sequential - slow
async function fetchAllSequential(ids) {
const results = [];
for (const id of ids) {
results.push(await fetchUser(id));
}
return results;
}
// ✅ Parallel - fast
async function fetchAllParallel(ids) {
return Promise.all(ids.map(id => fetchUser(id)));
}With 10 items at 100ms each, sequential takes 1000ms while parallel takes ~100ms.
Controlled Concurrency
Sometimes you need to limit parallelism:
async function mapWithConcurrency<T, R>(
items: T[],
fn: (item: T) => Promise<R>,
concurrency: number
): Promise<R[]> {
const results: R[] = [];
const executing: Promise<void>[] = [];
for (const item of items) {
const promise = fn(item).then(result => {
results.push(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;
}
// Process 100 items, 5 at a time
await mapWithConcurrency(items, processItem, 5);This prevents overwhelming APIs or databases with too many concurrent requests.
Error Handling Patterns
Handle errors gracefully with wrapper functions:
// Tuple-based error handling (Go-style)
async function safeAsync<T>(
promise: Promise<T>
): Promise<[T, null] | [null, Error]> {
try {
const result = await promise;
return [result, null];
} catch (error) {
return [null, error as Error];
}
}
// Usage
const [user, error] = await safeAsync(fetchUser(1));
if (error) {
console.error('Failed:', error.message);
return;
}
console.log(user.name);This pattern makes error handling explicit without try/catch blocks everywhere.
Retry Logic
Network requests fail. Build in resilience:
async function retry<T>(
fn: () => Promise<T>,
{ attempts = 3, delay = 1000, backoff = 2 } = {}
): Promise<T> {
let lastError: Error;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (i < attempts - 1) {
await new Promise(r => setTimeout(r, delay * Math.pow(backoff, i)));
}
}
}
throw lastError!;
}
// Usage
const user = await retry(() => fetchUser(1), {
attempts: 3,
delay: 1000,
backoff: 2
});Exponential backoff prevents thundering herds when services recover.
Best Practices
- Always handle errors — Unhandled promise rejections crash Node.js
- Prefer async/await — It's more readable than raw promises
- Parallelize when possible — Sequential awaits are a code smell
- Set timeouts — Don't wait forever for unresponsive services
- Use AbortController — Allow cancellation of long-running operations
Conclusion
Modern JavaScript gives us powerful tools for async programming. The key is understanding when to use each pattern:
- Sequential when order matters
- Parallel when operations are independent
- Controlled concurrency when resources are limited
- Retry when failures are transient
Master these patterns and async code becomes a joy rather than a headache.