Back to Blog
JavaScriptAsyncPromisesNode.js

Async JavaScript Patterns for Clean Code

Master asynchronous JavaScript. From promises to async/await to error handling to concurrency patterns.

B
Bootspring Team
Engineering
April 5, 2023
6 min read

Asynchronous code is essential for JavaScript, but it can quickly become messy. Here are patterns for writing clean, maintainable async code.

Promises Fundamentals#

1// Creating promises 2function fetchUser(id: string): Promise<User> { 3 return new Promise((resolve, reject) => { 4 db.query(`SELECT * FROM users WHERE id = $1`, [id], (err, result) => { 5 if (err) reject(err); 6 else if (!result.rows[0]) reject(new Error('User not found')); 7 else resolve(result.rows[0]); 8 }); 9 }); 10} 11 12// Chaining promises 13fetchUser('123') 14 .then((user) => fetchOrders(user.id)) 15 .then((orders) => calculateTotal(orders)) 16 .then((total) => console.log(total)) 17 .catch((error) => console.error(error)); 18 19// Promise.all - parallel execution 20const [users, products, orders] = await Promise.all([ 21 fetchUsers(), 22 fetchProducts(), 23 fetchOrders(), 24]); 25 26// Promise.allSettled - get all results regardless of failures 27const results = await Promise.allSettled([ 28 fetchUser('1'), 29 fetchUser('2'), 30 fetchUser('invalid'), 31]); 32 33results.forEach((result, index) => { 34 if (result.status === 'fulfilled') { 35 console.log(`User ${index}:`, result.value); 36 } else { 37 console.log(`User ${index} failed:`, result.reason); 38 } 39});

Async/Await Best Practices#

1// Clean async function 2async function processOrder(orderId: string): Promise<ProcessedOrder> { 3 const order = await fetchOrder(orderId); 4 const user = await fetchUser(order.userId); 5 const items = await fetchOrderItems(orderId); 6 7 const total = items.reduce((sum, item) => sum + item.price, 0); 8 9 return { 10 order, 11 user, 12 items, 13 total, 14 }; 15} 16 17// Parallel when possible 18async function processOrderParallel(orderId: string): Promise<ProcessedOrder> { 19 const order = await fetchOrder(orderId); 20 21 // These can run in parallel 22 const [user, items] = await Promise.all([ 23 fetchUser(order.userId), 24 fetchOrderItems(orderId), 25 ]); 26 27 return { order, user, items, total: calculateTotal(items) }; 28} 29 30// Avoid await in loops (use Promise.all instead) 31// ❌ Sequential - slow 32async function fetchUsersSlow(ids: string[]): Promise<User[]> { 33 const users: User[] = []; 34 for (const id of ids) { 35 users.push(await fetchUser(id)); 36 } 37 return users; 38} 39 40// ✅ Parallel - fast 41async function fetchUsersFast(ids: string[]): Promise<User[]> { 42 return Promise.all(ids.map((id) => fetchUser(id))); 43}

Error Handling#

1// Try-catch with async/await 2async function getUser(id: string): Promise<User | null> { 3 try { 4 const user = await db.user.findUnique({ where: { id } }); 5 return user; 6 } catch (error) { 7 logger.error({ error, id }, 'Failed to fetch user'); 8 return null; 9 } 10} 11 12// Custom error types 13class ApiError extends Error { 14 constructor( 15 message: string, 16 public statusCode: number, 17 public code: string 18 ) { 19 super(message); 20 this.name = 'ApiError'; 21 } 22} 23 24async function fetchWithErrorHandling(url: string): Promise<any> { 25 const response = await fetch(url); 26 27 if (!response.ok) { 28 throw new ApiError( 29 `Request failed: ${response.statusText}`, 30 response.status, 31 'FETCH_ERROR' 32 ); 33 } 34 35 return response.json(); 36} 37 38// Wrapper for consistent error handling 39function withErrorHandling<T extends (...args: any[]) => Promise<any>>( 40 fn: T, 41 errorHandler: (error: Error) => void 42): T { 43 return (async (...args: Parameters<T>) => { 44 try { 45 return await fn(...args); 46 } catch (error) { 47 errorHandler(error as Error); 48 throw error; 49 } 50 }) as T; 51} 52 53const safeGetUser = withErrorHandling(getUser, (error) => { 54 logger.error('User fetch failed', error); 55});

Concurrency Control#

1// Limit concurrent operations 2async function processWithLimit<T, R>( 3 items: T[], 4 fn: (item: T) => Promise<R>, 5 concurrency: number 6): Promise<R[]> { 7 const results: R[] = []; 8 const executing: Promise<void>[] = []; 9 10 for (const item of items) { 11 const promise = fn(item).then((result) => { 12 results.push(result); 13 }); 14 15 executing.push(promise); 16 17 if (executing.length >= concurrency) { 18 await Promise.race(executing); 19 executing.splice( 20 executing.findIndex((p) => p === promise), 21 1 22 ); 23 } 24 } 25 26 await Promise.all(executing); 27 return results; 28} 29 30// Usage 31const users = await processWithLimit( 32 userIds, 33 (id) => fetchUser(id), 34 5 // Max 5 concurrent requests 35); 36 37// Using p-limit library 38import pLimit from 'p-limit'; 39 40const limit = pLimit(5); 41 42const users = await Promise.all( 43 userIds.map((id) => limit(() => fetchUser(id))) 44);

Retry Pattern#

1interface RetryOptions { 2 maxAttempts: number; 3 delay: number; 4 backoff: 'linear' | 'exponential'; 5 shouldRetry?: (error: Error) => boolean; 6} 7 8async function withRetry<T>( 9 fn: () => Promise<T>, 10 options: RetryOptions 11): Promise<T> { 12 const { maxAttempts, delay, backoff, shouldRetry = () => true } = options; 13 14 let lastError: Error; 15 16 for (let attempt = 1; attempt <= maxAttempts; attempt++) { 17 try { 18 return await fn(); 19 } catch (error) { 20 lastError = error as Error; 21 22 if (attempt === maxAttempts || !shouldRetry(lastError)) { 23 throw lastError; 24 } 25 26 const waitTime = 27 backoff === 'exponential' ? delay * Math.pow(2, attempt - 1) : delay; 28 29 await sleep(waitTime); 30 } 31 } 32 33 throw lastError!; 34} 35 36// Usage 37const data = await withRetry( 38 () => fetchFromUnreliableApi(), 39 { 40 maxAttempts: 3, 41 delay: 1000, 42 backoff: 'exponential', 43 shouldRetry: (error) => error.message.includes('timeout'), 44 } 45);

Timeout Pattern#

1function withTimeout<T>( 2 promise: Promise<T>, 3 timeoutMs: number, 4 message = 'Operation timed out' 5): Promise<T> { 6 let timeoutId: NodeJS.Timeout; 7 8 const timeoutPromise = new Promise<never>((_, reject) => { 9 timeoutId = setTimeout(() => { 10 reject(new Error(message)); 11 }, timeoutMs); 12 }); 13 14 return Promise.race([promise, timeoutPromise]).finally(() => { 15 clearTimeout(timeoutId); 16 }); 17} 18 19// Usage 20try { 21 const result = await withTimeout(fetchData(), 5000, 'Fetch timed out'); 22} catch (error) { 23 if (error.message === 'Fetch timed out') { 24 // Handle timeout 25 } 26} 27 28// AbortController for cancellation 29async function fetchWithAbort(url: string, timeoutMs: number): Promise<any> { 30 const controller = new AbortController(); 31 const timeoutId = setTimeout(() => controller.abort(), timeoutMs); 32 33 try { 34 const response = await fetch(url, { signal: controller.signal }); 35 return response.json(); 36 } finally { 37 clearTimeout(timeoutId); 38 } 39}

Async Iteration#

1// Async generator for pagination 2async function* fetchAllPages<T>( 3 fetchPage: (cursor?: string) => Promise<{ data: T[]; nextCursor?: string }> 4): AsyncGenerator<T> { 5 let cursor: string | undefined; 6 7 do { 8 const { data, nextCursor } = await fetchPage(cursor); 9 10 for (const item of data) { 11 yield item; 12 } 13 14 cursor = nextCursor; 15 } while (cursor); 16} 17 18// Usage 19for await (const user of fetchAllPages(fetchUsersPage)) { 20 console.log(user); 21} 22 23// Collect all results 24const allUsers: User[] = []; 25for await (const user of fetchAllPages(fetchUsersPage)) { 26 allUsers.push(user); 27} 28 29// Process stream 30async function processStream(stream: AsyncIterable<Data>): Promise<void> { 31 for await (const chunk of stream) { 32 await processChunk(chunk); 33 } 34}

Queue Pattern#

1class AsyncQueue<T> { 2 private queue: (() => Promise<T>)[] = []; 3 private running = 0; 4 private concurrency: number; 5 6 constructor(concurrency = 1) { 7 this.concurrency = concurrency; 8 } 9 10 add(fn: () => Promise<T>): Promise<T> { 11 return new Promise((resolve, reject) => { 12 this.queue.push(async () => { 13 try { 14 resolve(await fn()); 15 } catch (error) { 16 reject(error); 17 } 18 }); 19 this.process(); 20 }); 21 } 22 23 private async process(): Promise<void> { 24 while (this.running < this.concurrency && this.queue.length > 0) { 25 const fn = this.queue.shift()!; 26 this.running++; 27 28 fn().finally(() => { 29 this.running--; 30 this.process(); 31 }); 32 } 33 } 34} 35 36// Usage 37const queue = new AsyncQueue<User>(3); 38 39const results = await Promise.all( 40 userIds.map((id) => queue.add(() => fetchUser(id))) 41);

Best Practices#

General: ✓ Use async/await over .then() ✓ Handle errors at appropriate levels ✓ Use Promise.all for parallel operations ✓ Avoid mixing callbacks and promises Performance: ✓ Parallelize independent operations ✓ Limit concurrency for external APIs ✓ Implement timeouts ✓ Use streaming for large data Error Handling: ✓ Use custom error types ✓ Include context in errors ✓ Implement retry for transient failures ✓ Log errors with stack traces

Conclusion#

Clean async code requires intentional patterns. Use async/await for readability, Promise.all for parallelization, and implement proper error handling, retries, and timeouts. These patterns make async code maintainable and robust.

Share this article

Help spread the word about Bootspring