Asynchronous patterns are fundamental in modern JavaScript and TypeScript development. Let’s explore the most important ones with practical examples and use cases.
1. Callbacks
Callbacks were the first solution for handling asynchronous operations, but they can lead to “callback hell” when nested:
interface User {
id: number;
name: string;
email: string;
}
interface UserPreferences {
userId: number;
theme: string;
notifications: boolean;
}
interface UserStats {
userId: number;
lastLogin: Date;
sessionCount: number;
}
// Callback hell example
function getUserData(userId: number, callback: (user: User) => void) {
setTimeout(() => {
callback({
id: userId,
name: "John Doe",
email: "john@example.com"
});
}, 1000);
}
function getUserPreferences(user: User, callback: (prefs: UserPreferences) => void) {
setTimeout(() => {
callback({
userId: user.id,
theme: "dark",
notifications: true
});
}, 1000);
}
function getUserStats(user: User, callback: (stats: UserStats) => void) {
setTimeout(() => {
callback({
userId: user.id,
lastLogin: new Date(),
sessionCount: 42
});
}, 1000);
}
// Usage example - Callback Hell
getUserData(1, (user) => {
getUserPreferences(user, (prefs) => {
getUserStats(user, (stats) => {
console.log({
user,
preferences: prefs,
statistics: stats
});
});
});
});
// Real-world use case: File operations in Node.js (legacy style)
import fs from 'fs';
fs.readFile('config.json', 'utf8', (err, data) => {
if (err) {
console.error('Error reading file:', err);
return;
}
const config = JSON.parse(data);
fs.writeFile('config.backup.json', JSON.stringify(config), (err) => {
if (err) {
console.error('Error writing backup:', err);
return;
}
console.log('Backup created successfully');
});
});
2. Promises
Promises provide a cleaner syntax and better error handling capabilities:
interface User {
id: number;
name: string;
email: string;
}
interface UserPreferences {
userId: number;
theme: string;
notifications: boolean;
}
// Promise-based implementations
function getUserData(userId: number): Promise<User> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
id: userId,
name: "John Doe",
email: "john@example.com"
});
}, 1000);
});
}
function getUserPreferences(user: User): Promise<UserPreferences> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
userId: user.id,
theme: "dark",
notifications: true
});
}, 1000);
});
}
// Usage example
getUserData(1)
.then(user => getUserPreferences(user))
.then(prefs => {
console.log('User preferences:', prefs);
})
.catch(error => {
console.error('Error:', error);
});
// Real-world use case: API calls with fetch
interface GithubRepo {
id: number;
name: string;
description: string;
}
function fetchGithubRepos(username: string): Promise<GithubRepo[]> {
return fetch(`https://api.github.com/users/${username}/repos`)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
});
}
// Promise.all example for parallel execution
Promise.all([
fetchGithubRepos('user1'),
fetchGithubRepos('user2')
])
.then(([user1Repos, user2Repos]) => {
console.log('User 1 repos:', user1Repos);
console.log('User 2 repos:', user2Repos);
})
.catch(error => {
console.error('Error fetching repos:', error);
});
3. Async/Await
The most modern and readable way to handle asynchronous operations:
interface User {
id: number;
name: string;
email: string;
}
interface UserPreferences {
userId: number;
theme: string;
notifications: boolean;
}
// Async/await implementation
async function getUserProfile(userId: number) {
try {
const user = await getUserData(userId);
const preferences = await getUserPreferences(user);
return { user, preferences };
} catch (error) {
console.error('Error fetching user profile:', error);
throw error;
}
}
// Real-world use case: Data fetching in React
interface Post {
id: number;
title: string;
content: string;
}
async function fetchBlogPosts(): Promise<Post[]> {
const response = await fetch('https://api.example.com/posts');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
}
// React component example
import React, { useEffect, useState } from 'react';
function BlogPosts() {
const [posts, setPosts] = useState<Post[]>([]);
const [error, setError] = useState<string>('');
useEffect(() => {
async function loadPosts() {
try {
const fetchedPosts = await fetchBlogPosts();
setPosts(fetchedPosts);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
}
}
loadPosts();
}, []);
if (error) return <div>Error: {error}</div>;
if (!posts.length) return <div>Loading...</div>;
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
</div>
);
}
4. Generators and Async Generators
Generators provide a unique way to handle asynchronous operations with more control over the execution flow:
// Generator function example
function* numberGenerator() {
yield 1;
yield 2;
yield 3;
}
// Async generator example
async function* createAsyncNumberGenerator() {
for (let i = 0; i < 3; i++) {
await new Promise(resolve => setTimeout(resolve, 1000));
yield i;
}
}
// Real-world use case: Paginated API calls
interface PaginatedResponse<T> {
data: T[];
nextPage: number | null;
}
async function* fetchPaginatedData<T>(
baseUrl: string,
pageSize: number = 10
): AsyncGenerator<T[], void, unknown> {
let currentPage = 1;
while (true) {
const response = await fetch(
`${baseUrl}?page=${currentPage}&pageSize=${pageSize}`
);
const result: PaginatedResponse<T> = await response.json();
yield result.data;
if (!result.nextPage) break;
currentPage = result.nextPage;
}
}
// Usage example
async function processAllPages() {
const generator = fetchPaginatedData<User>('https://api.example.com/users');
for await (const pageData of generator) {
console.log('Processing page data:', pageData);
// Process each page of data
}
}
5. Observable Pattern
While not built into JavaScript, the Observable pattern (commonly implemented through libraries like RxJS) provides powerful tools for handling streams of asynchronous data:
import { Observable, from, interval } from 'rxjs';
import { map, filter, takeUntil } from 'rxjs/operators';
// Basic Observable example
const numberStream$ = new Observable<number>(subscriber => {
let count = 0;
const interval = setInterval(() => {
subscriber.next(count++);
if (count > 5) {
subscriber.complete();
clearInterval(interval);
}
}, 1000);
// Cleanup on unsubscribe
return () => clearInterval(interval);
});
// Real-world use case: Real-time data streaming
interface StockPrice {
symbol: string;
price: number;
timestamp: Date;
}
class StockPriceService {
getStockPriceUpdates(symbol: string): Observable<StockPrice> {
// Simulate WebSocket connection
return new Observable<StockPrice>(subscriber => {
const ws = new WebSocket(`wss://api.example.com/stocks/${symbol}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
subscriber.next({
symbol,
price: data.price,
timestamp: new Date(data.timestamp)
});
};
ws.onerror = (error) => subscriber.error(error);
ws.onclose = () => subscriber.complete();
// Cleanup on unsubscribe
return () => ws.close();
});
}
}
// Usage example
const stockService = new StockPriceService();
const subscription = stockService.getStockPriceUpdates('AAPL')
.pipe(
filter(update => update.price > 150),
map(update => ({
...update,
price: update.price.toFixed(2)
}))
)
.subscribe({
next: (price) => console.log('Stock price update:', price),
error: (error) => console.error('Error:', error),
complete: () => console.log('Stream completed')
});
// Cleanup
setTimeout(() => subscription.unsubscribe(), 10000);
Each of these patterns has its own use cases and advantages:
- Callbacks are still useful for simple event handlers and legacy code maintenance
- Promises are perfect for single asynchronous operations and are the foundation for more advanced patterns
- Async/Await provides the most readable syntax for sequential asynchronous operations
- Generators excel at handling paginated data and creating custom iteration protocols
- Observables are ideal for real-time data streams and complex event handling