Modern JavaScript Async Patterns in 2019
Async JavaScript has come a long way since callback hell. With async/await now widely supported (even in Node.js 8+), I’ve been refactoring our codebase to use modern patterns.
This post covers the async patterns I use in production, including some gotchas I learned the hard way.
Table of Contents
The Evolution: Callbacks → Promises → Async/Await
We’ve gone through three generations of async code:
// 2010: Callback hell
getUserData(userId, function(err, user) {
if (err) return handleError(err);
getOrders(user.id, function(err, orders) {
if (err) return handleError(err);
getOrderDetails(orders[0].id, function(err, details) {
if (err) return handleError(err);
console.log(details);
});
});
});
// 2015: Promises
getUserData(userId)
.then(user => getOrders(user.id))
.then(orders => getOrderDetails(orders[0].id))
.then(details => console.log(details))
.catch(handleError);
// 2019: Async/await
try {
const user = await getUserData(userId);
const orders = await getOrders(user.id);
const details = await getOrderDetails(orders[0].id);
console.log(details);
} catch (err) {
handleError(err);
}
Async/await is just syntactic sugar over Promises, but it makes code much more readable.
Pattern 1: Always Use Try/Catch
This seems obvious, but I see unhandled promise rejections constantly:
// Bad: unhandled rejection
async function loadUser(id) {
const user = await api.getUser(id); // If this fails, unhandled rejection!
return user;
}
// Good: handle errors
async function loadUser(id) {
try {
const user = await api.getUser(id);
return user;
} catch (err) {
console.error('Failed to load user:', err);
throw err; // Re-throw or handle appropriately
}
}
In Node.js, unhandled rejections will crash your process in future versions. Always handle errors.
Pattern 2: Parallel Execution with Promise.all
Don’t await sequentially when you can run in parallel:
// Bad: Sequential (slow)
async function loadData() {
const users = await api.getUsers(); // 100ms
const products = await api.getProducts(); // 100ms
const orders = await api.getOrders(); // 100ms
// Total: 300ms
return { users, products, orders };
}
// Good: Parallel (fast)
async function loadData() {
const [users, products, orders] = await Promise.all([
api.getUsers(),
api.getProducts(),
api.getOrders()
]);
// Total: 100ms (all run concurrently)
return { users, products, orders };
}
This is one of the most common performance issues I see. Use Promise.all when operations are independent.
Pattern 3: Handle Partial Failures with Promise.allSettled
Promise.all fails fast - if any promise rejects, the whole thing fails. Sometimes you want to continue even if some operations fail:
// Promise.all fails if any promise rejects
try {
const [user, posts, comments] = await Promise.all([
api.getUser(id),
api.getPosts(id),
api.getComments(id) // If this fails, we get nothing!
]);
} catch (err) {
// Lost user and posts data
}
// Promise.allSettled waits for all, regardless of success/failure
const results = await Promise.allSettled([
api.getUser(id),
api.getPosts(id),
api.getComments(id)
]);
const user = results[0].status === 'fulfilled' ? results[0].value : null;
const posts = results[1].status === 'fulfilled' ? results[1].value : [];
const comments = results[2].status === 'fulfilled' ? results[2].value : [];
// We get user and posts even if comments failed
Promise.allSettled is new in ES2020, but there’s a polyfill. I use it for non-critical data fetching.
Pattern 4: Race Conditions with Promise.race
Use Promise.race for timeouts or fallbacks:
// Timeout pattern
async function fetchWithTimeout(url, timeout = 5000) {
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Timeout')), timeout);
});
return Promise.race([
fetch(url),
timeoutPromise
]);
}
// Fallback pattern
async function fetchWithFallback(primaryUrl, fallbackUrl) {
try {
return await Promise.race([
fetch(primaryUrl),
new Promise((resolve) => {
setTimeout(() => resolve(fetch(fallbackUrl)), 1000);
})
]);
} catch (err) {
return fetch(fallbackUrl);
}
}
I use the timeout pattern for external API calls. It prevents hanging requests from blocking the app.
Pattern 5: Sequential Processing with for…of
When you need to process items sequentially (e.g., rate limiting):
// Bad: forEach doesn't wait for async
async function processUsers(users) {
users.forEach(async (user) => {
await api.updateUser(user); // These all run in parallel!
});
console.log('Done'); // Logs before updates finish
}
// Good: for...of waits for each iteration
async function processUsers(users) {
for (const user of users) {
await api.updateUser(user); // Waits for each update
}
console.log('Done'); // Logs after all updates finish
}
Use for...of when order matters or you need to rate-limit.
Pattern 6: Controlled Concurrency
Sometimes you want parallel execution but with a limit:
async function processWithConcurrency(items, concurrency, processor) {
const results = [];
const executing = [];
for (const item of items) {
const promise = processor(item).then(result => {
executing.splice(executing.indexOf(promise), 1);
return result;
});
results.push(promise);
executing.push(promise);
if (executing.length >= concurrency) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
// Process 100 items, max 5 at a time
await processWithConcurrency(items, 5, async (item) => {
return api.processItem(item);
});
This is useful for API calls with rate limits. I use this pattern for batch processing.
Pattern 7: Retry with Exponential Backoff
For transient failures:
async function retry(fn, maxAttempts = 3, delay = 1000) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === maxAttempts) {
throw err;
}
const backoff = delay * Math.pow(2, attempt - 1);
console.log(`Attempt ${attempt} failed, retrying in ${backoff}ms`);
await new Promise(resolve => setTimeout(resolve, backoff));
}
}
}
// Usage
const user = await retry(() => api.getUser(id), 3, 1000);
This has saved us from cascading failures when external services have brief outages.
Pattern 8: Async Iteration with for await…of
For processing async iterables (streams, generators):
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++;
}
}
// Process all pages
for await (const items of fetchPages('/api/users')) {
console.log(`Processing ${items.length} items`);
await processItems(items);
}
This is great for paginated APIs. You can process data as it arrives instead of loading everything into memory.
Pattern 9: Cancellation with AbortController
For cancelling in-flight requests:
const controller = new AbortController();
const signal = controller.signal;
// Start a request
const promise = fetch('/api/data', { signal });
// Cancel it
controller.abort();
// Handle cancellation
try {
const response = await promise;
} catch (err) {
if (err.name === 'AbortError') {
console.log('Request was cancelled');
}
}
I use this for search-as-you-type features. When the user types, I cancel the previous request:
let currentController = null;
async function search(query) {
// Cancel previous request
if (currentController) {
currentController.abort();
}
currentController = new AbortController();
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: currentController.signal
});
const results = await response.json();
displayResults(results);
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Search failed:', err);
}
}
}
Pattern 10: Async Initialization
For classes that need async setup:
class Database {
constructor() {
this.ready = this.init();
}
async init() {
this.connection = await createConnection();
this.cache = await loadCache();
return this;
}
async query(sql) {
await this.ready; // Wait for initialization
return this.connection.query(sql);
}
}
// Usage
const db = new Database();
await db.ready; // Wait for initialization
const results = await db.query('SELECT * FROM users');
This pattern ensures the class is fully initialized before use.
Common Gotchas
1. Forgetting to await
// Bug: doesn't wait for save
async function updateUser(user) {
saveToDatabase(user); // Missing await!
console.log('Saved'); // Logs before save completes
}
Use a linter (ESLint with no-floating-promises rule) to catch this.
2. Async in array methods
// Doesn't work as expected
const results = items.map(async (item) => {
return await processItem(item);
});
// results is an array of Promises, not values!
// Fix: await Promise.all
const results = await Promise.all(
items.map(async (item) => {
return await processItem(item);
})
);
3. Try/catch scope
// Bug: catch doesn't catch the error
try {
setTimeout(async () => {
await riskyOperation(); // Error not caught!
}, 1000);
} catch (err) {
console.error(err);
}
// Fix: put try/catch inside async function
setTimeout(async () => {
try {
await riskyOperation();
} catch (err) {
console.error(err);
}
}, 1000);
4. Memory leaks with Promise.all
// Bug: if one promise rejects, others keep running
await Promise.all([
longRunningTask1(),
longRunningTask2(),
quickFailingTask() // Fails immediately
]);
// longRunningTask1 and 2 keep running in background!
Use Promise.race or AbortController to cancel other tasks.
Real-World Example: API Client
Here’s how these patterns come together:
class APIClient {
constructor(baseURL, options = {}) {
this.baseURL = baseURL;
this.timeout = options.timeout || 5000;
this.retries = options.retries || 3;
}
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
return retry(async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} finally {
clearTimeout(timeout);
}
}, this.retries);
}
async batchRequest(endpoints, concurrency = 5) {
return processWithConcurrency(
endpoints,
concurrency,
(endpoint) => this.request(endpoint)
);
}
}
// Usage
const api = new APIClient('https://api.example.com', {
timeout: 10000,
retries: 3
});
const user = await api.request('/users/123');
const [users, posts, comments] = await api.batchRequest([
'/users',
'/posts',
'/comments'
], 3);
Conclusion
Async/await has made JavaScript async code much more readable, but you still need to understand the underlying Promise mechanics.
Key takeaways:
- Always handle errors with try/catch
- Use
Promise.allfor parallel execution - Use
Promise.allSettledfor partial failures - Use
for...offor sequential processing - Implement retry logic for transient failures
- Use
AbortControllerfor cancellation - Watch out for common gotchas (forgetting await, async in array methods)
These patterns have made our codebase more robust and performant. Async JavaScript is powerful when used correctly.
The future looks even better - async iterators, top-level await, and better error handling are coming. But these patterns will remain relevant.