Mastering Async JavaScript: Build an API Data Aggregator
Introduction: In modern web development, working with multiple APIs simultaneously is a common pattern — whether for building dashboards, aggregating data from several endpoints, or orchestrating backend microservices. JavaScript’s async/await syntax makes it possible to write asynchronous code that looks clean and behaves predictably. In this tutorial, we’ll build a lightweight API Data Aggregator that fetches from multiple sources, merges the results, and handles errors gracefully.
1. Understanding Async/Await and Concurrency
Before jumping into code, let’s revisit the behavior of async/await. The async keyword defines a function that returns a Promise, and await pauses execution until that Promise settles. However, await does not make code run in parallel on its own—it merely provides a nicer syntax for reactivity.
// Example: Sequential async calls
async function getDataSequential() {
const user = await fetch('https://jsonplaceholder.typicode.com/users/1').then(r => r.json());
const posts = await fetch('https://jsonplaceholder.typicode.com/posts?userId=1').then(r => r.json());
console.log({ user, posts });
}
This approach is fine for short chains but becomes inefficient when we have independent requests. Good news: we can easily run them concurrently using Promise.all().
2. Fetching Multiple APIs in Parallel
To aggregate data from multiple APIs, we can issue parallel requests using Promise.all(). This method accepts an array of Promises and resolves once all of them complete, or rejects if any Promise fails.
async function getDataParallel() {
try {
const [users, posts, todos] = await Promise.all([
fetch('https://jsonplaceholder.typicode.com/users').then(r => r.json()),
fetch('https://jsonplaceholder.typicode.com/posts').then(r => r.json()),
fetch('https://jsonplaceholder.typicode.com/todos').then(r => r.json())
]);
return { users, posts, todos };
} catch (error) {
console.error('Error fetching data:', error);
return {};
}
}
Why this works: Promise.all() doesn’t wait for each request sequentially — it fires them all at once and awaits their collective completion. This drastically reduces latency when working with multiple endpoints.
3. Merging and Normalizing API Responses
For an aggregator, merging data is essential. Let’s assume we want to combine user information with their posts and todos to produce a unified structure.
async function aggregateUserData() {
const { users, posts, todos } = await getDataParallel();
const aggregated = users.map(user => {
return {
...user,
posts: posts.filter(post => post.userId === user.id),
todos: todos.filter(todo => todo.userId === user.id)
};
});
return aggregated;
}
This merges our fetched datasets into a single array where each user has related posts and todos. This approach is flexible; you can merge reviews, comments, or transactions from other endpoints similarly.
4. Handling Errors Gracefully
In real-world scenarios, one API may fail while others succeed. Using Promise.all() causes all requests to fail if one fails. To handle this gracefully, use Promise.allSettled(), which always resolves and reports each result.
async function getDataSafely() {
const results = await Promise.allSettled([
fetch('https://jsonplaceholder.typicode.com/users').then(r => r.json()),
fetch('https://jsonplaceholder.typicode.com/posts').then(r => r.json()),
fetch('https://jsonplaceholder.typicode.com/todos').then(r => r.json())
]);
const parsed = results.map(result => result.status === 'fulfilled' ? result.value : []);
const [users, posts, todos] = parsed;
return { users, posts, todos };
}
This implementation ensures partial data availability — if one endpoint fails, we still return data from others. This pattern is ideal for dashboards or APIs where partial content is acceptable.
5. Optimizing Performance and Reusability
When scaling this pattern for production, consider a few performance improvements:
- Connection caching: Use global
fetchconfigurations or libraries like Axios to reuse persistent connections. - Throttling/Rate limiting: Implement a request queue if dealing with rate-limited APIs.
- Reusable modules: Encapsulate request logic for different APIs into separate modules or services so you can easily add or modify endpoints.
Example of modular design:
// apiClient.js
export async function fetchJSON(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
return response.json();
}
// aggregator.js
import { fetchJSON } from './apiClient.js';
export async function aggregate() {
const [users, posts] = await Promise.all([
fetchJSON('https://jsonplaceholder.typicode.com/users'),
fetchJSON('https://jsonplaceholder.typicode.com/posts')
]);
return users.map(u => ({ ...u, posts: posts.filter(p => p.userId === u.id) }));
}
Performance Tip: Consider caching results (in memory or Redis) to avoid redundant calls if the data doesn’t change frequently.
Conclusion
By mastering async/await and patterns like Promise.all() and Promise.allSettled(), you can build efficient, reliable data aggregators that handle diverse APIs simultaneously. These techniques form the backbone of real-world asynchronous applications, enabling better performance, fault tolerance, and developer productivity.
Useful links:


