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.