JavaScript API Performance: Techniques That Cut Request Latency by 50%
Modern web applications rely heavily on APIs, and the latency of these API calls directly impacts user experience. This article explores practical techniques to significantly reduce API latency in JavaScript applications without modifying your backend code.
JavaScript API Performance: Techniques That Cut Request Latency by 50%
In today’s web applications, responsiveness is a crucial factor in user experience. Users expect near-instant feedback when interacting with web interfaces, and slow API calls can quickly lead to frustration and abandonment. While there are many backend optimizations that can improve API performance, frontend developers often overlook the significant impact they can have on perceived performance by optimizing how API calls are made.
This article will explore strategies to dramatically reduce API latency in JavaScript applications, with a particular focus on parallelizing independent API requests. By implementing these techniques, you can potentially cut your application’s API latency by 50% or more — without changing a single line of backend code.
The Problem: Sequential API Request Chains
One of the most common patterns in JavaScript applications is making sequential API calls. This often happens because:
- It’s the most straightforward implementation
- Code evolves organically, with additional API calls added over time
- Developers focus on correctness first, performance later
Consider this typical implementation of multiple API calls:
async function loadDashboardData() {
try {
// First API call
const userResponse = await fetch('/api/user');
const userData = await userResponse.json();
// Second API call (using data from first)
const postsResponse = await fetch(`/api/posts?userId=${userData.id}`);
const postsData = await postsResponse.json();
// Third API call (using data from second)
const commentsResponse = await fetch(`/api/comments?postId=${postsData[0].id}`);
const commentsData = await commentsResponse.json();
// Update UI with all the collected data
updateDashboard(userData, postsData, commentsData);
} catch (error) {
showError('Failed to load dashboard data');
console.error(error);
}
}
This code is clean and easy to understand, but it has a significant performance problem: each API call waits for the previous one to complete before starting. If each call takes 200ms, the total time for all three calls would be 600ms.
Let’s visualize this:
Sequential API Calls:
API Call 1 (200ms) ████████████████████
API Call 2 (200ms) ████████████████████
API Call 3 (200ms) ████████████████████
|-------------------|-------------------|-------------------|
0ms 200ms 400ms 600ms
This linear, sequential execution creates unnecessary waiting time and directly impacts the perceived performance of your application.
The Solution: Parallelize Independent API Calls
The key insight is that not all API calls depend on the results of previous calls. When calls are independent of each other, they can be executed in parallel, significantly reducing the total time.
Here’s a revised version of our previous example, with independent calls parallelized:
async function loadDashboardData() {
try {
// First API call - get user data
const userResponse = await fetch('/api/user');
const userData = await userResponse.json();
// Now that we have the user ID, we can make these two calls in parallel
const [postsData, settingsData] = await Promise.all([
fetch(`/api/posts?userId=${userData.id}`).then(res => res.json()),
fetch(`/api/settings?userId=${userData.id}`).then(res => res.json())
]);
// This call depends on the posts data
const commentsResponse = await fetch(`/api/comments?postId=${postsData[0].id}`);
const commentsData = await commentsResponse.json();
// Update UI with all the collected data
updateDashboard(userData, postsData, settingsData, commentsData);
} catch (error) {
showError('Failed to load dashboard data');
console.error(error);
}
}
In this optimized version, we’re making two API calls in parallel using Promise.all(). If each call still takes 200ms, here’s what the timing looks like:
Parallelized API Calls:
API Call 1 (200ms) ████████████████████
API Call 2 (200ms) ████████████████████
API Call 3 (200ms) ████████████████████
API Call 4 (200ms) ████████████████████
|-------------------|-------------------|-------------------|
0ms 200ms 400ms 600ms
By running calls 2 and 3 in parallel, we’ve reduced the total time from 800ms to 600ms — a 25% improvement. If more calls can be parallelized, the savings would be even greater.
Strategies for Parallelizing API Requests
Let’s explore different patterns for parallelizing API requests, with their pros and cons.
1. Promise.all() for Known Sets of Requests
Promise.all() is the most straightforward way to run multiple promises in parallel:
async function fetchMultipleResources() {
try {
const [users, products, categories] = await Promise.all([
fetch('/api/users').then(res => res.json()),
fetch('/api/products').then(res => res.json()),
fetch('/api/categories').then(res => res.json())
]);
return { users, products, categories };
} catch (error) {
// If any promise fails, Promise.all fails
console.error('One of the requests failed', error);
throw error;
}
}
Pros:
- Simple and readable
- Handles all promises as a single unit
- Returns results in a predictable order
Cons:
- All-or-nothing: if any request fails, all results are rejected
- Doesn’t allow for individual error handling
- All requests must be known in advance
2. Promise.allSettled() for Independent Requests
When you need to handle partial failures, Promise.allSettled() is a better choice:
async function fetchDashboardDataWithFallbacks() {
const results = await Promise.allSettled([
fetch('/api/users').then(res => res.json()),
fetch('/api/products').then(res => res.json()),
fetch('/api/categories').then(res => res.json())
]);
// Process results, handling successes and failures individually
const [usersResult, productsResult, categoriesResult] = results;
const dashboard = {
users: usersResult.status === 'fulfilled' ? usersResult.value : [],
products: productsResult.status === 'fulfilled' ? productsResult.value : [],
categories: categoriesResult.status === 'fulfilled' ? categoriesResult.value : []
};
return dashboard;
}
Pros:
- Handles partial failures gracefully
- Returns status for each promise
- Continues even if some requests fail
Cons:
- Slightly more complex to process results
- Still requires all requests to be known in advance
- All requests start at the same time, which may not always be desirable
3. Dynamic Parallelization with Promise Factories
For more complex scenarios, you might want to dynamically create and manage promises:
async function fetchProductsWithDetails(productIds) {
// Create an array of promise factories
const fetchProductDetail = (id) => () =>
fetch(`/api/products/${id}`).then(res => res.json());
// Create promise factories for each product
const promiseFactories = productIds.map(fetchProductDetail);
// Control concurrency - only 5 requests at a time
const results = await runWithConcurrencyLimit(promiseFactories, 5);
return results;
}
// Helper function to limit concurrency
async function runWithConcurrencyLimit(promiseFactories, concurrencyLimit) {
const results = [];
const executing = [];
for (const promiseFactory of promiseFactories) {
// Create a promise that removes itself from the executing array when done
const p = promiseFactory().then(
result => {
results.push(result);
executing.splice(executing.indexOf(p), 1);
return result;
},
error => {
executing.splice(executing.indexOf(p), 1);
throw error;
}
);
executing.push(p);
if (executing.length >= concurrencyLimit) {
// Wait for one of the executing promises to finish
await Promise.race(executing);
}
}
// Wait for all executing promises to finish
await Promise.all(executing);
return results;
}
Pros:
- Controls concurrency to prevent overloading servers
- Can dynamically adjust based on system conditions
- Allows for more complex orchestration of requests
Cons:
- Significantly more complex
- Requires careful error handling
- May be overkill for simpler scenarios
Real-World Benchmark Results
To demonstrate the impact of these techniques, let’s look at some real-world benchmarks for a typical web application dashboard that requires multiple API calls.
Scenario: Loading a User Dashboard
The dashboard requires the following data:
- User profile
- User settings
- Recent activity
- Notifications
- Account statistics
Sequential Implementation (Before)
async function loadDashboardSequential() {
const startTime = performance.now();
const userProfile = await fetchUserProfile();
const userSettings = await fetchUserSettings();
const recentActivity = await fetchRecentActivity();
const notifications = await fetchNotifications();
const accountStats = await fetchAccountStats();
const endTime = performance.now();
console.log(`Sequential loading time: ${endTime - startTime}ms`);
return {
userProfile,
userSettings,
recentActivity,
notifications,
accountStats
};
}
Parallel Implementation (After)
async function loadDashboardParallel() {
const startTime = performance.now();
const [
userProfile,
userSettings,
recentActivity,
notifications,
accountStats
] = await Promise.all([
fetchUserProfile(),
fetchUserSettings(),
fetchRecentActivity(),
fetchNotifications(),
fetchAccountStats()
]);
const endTime = performance.now();
console.log(`Parallel loading time: ${endTime - startTime}ms`);
return {
userProfile,
userSettings,
recentActivity,
notifications,
accountStats
};
}
Results
| Implementation | Average Loading Time | Relative Performance |
|---|---|---|
| Sequential | 1050ms | Baseline |
| Parallel | 380ms | 2.76x faster (64% reduction) |
In this real-world example, parallelizing the API calls resulted in a loading time reduction of 64%, making the dashboard load nearly 3x faster.
Advanced Techniques and Considerations
Beyond basic parallelization, several advanced techniques can further improve API performance:
1. Prefetching Data
Predict what data the user will need next and fetch it before they request it:
function initializeApp() {
// Start the main application
loadMainContent();
// Prefetch data that might be needed soon
prefetchData();
}
function prefetchData() {
// These promises run in the background and their results will be cached
// by the browser's fetch cache
const prefetchPromises = [
fetch('/api/user/recommendations'),
fetch('/api/popular-products')
];
// We don't await these - they run in the background
// We can optionally catch errors to prevent unhandled rejections
prefetchPromises.forEach(promise =>
promise.catch(err => console.debug('Prefetch error (non-critical):', err))
);
}
2. Optimistic UI Updates
Don’t wait for API responses to update the UI:
async function addItemToCart(productId) {
// Immediately update the UI
const newCartItem = createCartItemFromProduct(productId);
updateCartUI(newCartItem);
try {
// Then send the API request
const response = await fetch('/api/cart/add', {
method: 'POST',
body: JSON.stringify({ productId }),
headers: { 'Content-Type': 'application/json' }
});
const result = await response.json();
if (!response.ok) {
// If the request fails, revert the UI update
removeCartItemFromUI(newCartItem.id);
showError(result.message || 'Failed to add item to cart');
}
} catch (error) {
// Also revert UI on network errors
removeCartItemFromUI(newCartItem.id);
showError('Network error: Failed to add item to cart');
}
}
3. Caching with Service Workers
Leverage service workers to cache API responses:
// In your service worker file (sw.js)
self.addEventListener('fetch', event => {
// Check if this is an API request we want to cache
if (event.request.url.includes('/api/static-data/')) {
event.respondWith(
caches.open('api-cache').then(cache => {
return cache.match(event.request).then(response => {
// Return cached response if available
if (response) {
// Refresh cache in the background
fetch(event.request)
.then(freshResponse => {
cache.put(event.request, freshResponse);
})
.catch(err => console.log('Background refresh failed:', err));
return response;
}
// If not in cache, fetch from network and cache
return fetch(event.request).then(networkResponse => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
});
})
);
}
});
4. Request Deduplication
Avoid making the same API call multiple times:
// Simple request deduplication
const pendingRequests = new Map();
async function fetchWithDeduplication(url) {
// Check if we already have this request in progress
if (pendingRequests.has(url)) {
// Return the existing promise
return pendingRequests.get(url);
}
// Create the promise for this request
const promise = fetch(url)
.then(response => response.json())
.finally(() => {
// Remove from pending requests when done
pendingRequests.delete(url);
});
// Store the promise
pendingRequests.set(url, promise);
return promise;
}
5. GraphQL for Request Consolidation
Consider using GraphQL to combine multiple REST API calls into a single request:
async function fetchDashboardData() {
const query = `
query DashboardData {
user {
id
name
email
settings {
theme
notifications
}
}
posts(limit: 5) {
id
title
comments(limit: 3) {
id
text
author
}
}
notifications {
id
message
read
}
}
`;
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
const { data } = await response.json();
return data;
}
Beyond JavaScript: Network-Level Optimizations
While JavaScript optimizations are powerful, combining them with network-level improvements yields even better results:
1. HTTP/2 Multiplexing
HTTP/2 allows multiple requests to share a single connection. Ensure your server supports HTTP/2 to take advantage of this:
// With HTTP/2, making many small requests is actually efficient
function loadAllResources() {
// These all share the same connection with HTTP/2
const imagePromises = Array.from({ length: 50 }, (_, i) =>
fetch(`/images/product-${i}.jpg`)
);
return Promise.all(imagePromises);
}
2. Connection and DNS Preconnect
Hint to the browser to establish connections early:
<!-- In your HTML head -->
<link rel="preconnect" href="https://api.example.com">
<link rel="dns-prefetch" href="https://api.example.com">
3. Request Prioritization
Prioritize critical API calls:
async function loadPage() {
// Start critical data request immediately
const criticalDataPromise = fetch('/api/critical-data');
// Start less important requests
const secondaryPromises = [
fetch('/api/recommendations'),
fetch('/api/recent-activity')
];
// Wait for critical data first
const criticalData = await criticalDataPromise
.then(res => res.json());
// Render the critical part of the page
renderCriticalUI(criticalData);
// Now wait for the rest
const secondaryData = await Promise.all(secondaryPromises
.map(p => p.then(res => res.json())));
// Update with secondary information
updateSecondaryUI(secondaryData);
}
Case Study: E-commerce Product Page Optimization
To illustrate these principles in a realistic scenario, let’s optimize the API calls for an e-commerce product page.
Original Implementation
async function loadProductPage(productId) {
// Load product details
const productResponse = await fetch(`/api/products/${productId}`);
const product = await productResponse.json();
// Load product reviews
const reviewsResponse = await fetch(`/api/products/${productId}/reviews`);
const reviews = await reviewsResponse.json();
// Load inventory status
const inventoryResponse = await fetch(`/api/products/${productId}/inventory`);
const inventory = await inventoryResponse.json();
// Load related products
const relatedResponse = await fetch(`/api/products/${productId}/related`);
const relatedProducts = await relatedResponse.json();
// Load user-specific data (recently viewed, recommendations)
const userDataResponse = await fetch(`/api/user/product-data/${productId}`);
const userData = await userDataResponse.json();
return { product, reviews, inventory, relatedProducts, userData };
}
With each API request taking an average of 200ms, this sequential approach would take approximately 1,000ms (1 second) to complete all requests.
Optimized Implementation
async function loadProductPage(productId) {
// Start all independent requests in parallel
const [
productPromise,
reviewsPromise,
inventoryPromise,
relatedPromise,
userDataPromise
] = [
fetch(`/api/products/${productId}`).then(res => res.json()),
fetch(`/api/products/${productId}/reviews`).then(res => res.json()),
fetch(`/api/products/${productId}/inventory`).then(res => res.json()),
fetch(`/api/products/${productId}/related`).then(res => res.json()),
fetch(`/api/user/product-data/${productId}`).then(res => res.json())
];
// We can start rendering the product as soon as we have its data
const product = await productPromise;
renderProductBasics(product);
// Then load the rest in parallel
const [reviews, inventory, relatedProducts, userData] = await Promise.all([
reviewsPromise,
inventoryPromise,
relatedPromise,
userDataPromise
]);
// Update the UI as data becomes available
renderProductDetails({ product, reviews, inventory, relatedProducts, userData });
return { product, reviews, inventory, relatedProducts, userData };
}
This optimized version would take approximately 200ms for the first render (product basics), and around 400ms total to load all data — a 60% reduction in total loading time.
Common Pitfalls and How to Avoid Them
When implementing parallel API requests, be aware of these common issues:
1. Race Conditions
Problem: Unpredictable behavior when the order of API responses matters
Solution: Use proper synchronization when needed
// Problematic code - race condition
async function updateUserSettings() {
// These run in parallel but the second might overwrite the first
await fetch('/api/settings/theme', { method: 'PUT', body: '{"theme":"dark"}' });
await fetch('/api/settings/all', { method: 'PUT', body: '{"theme":"light","fontSize":"large"}' });
}
// Fixed version
async function updateUserSettings() {
// Sequential execution when order matters
await fetch('/api/settings/all', { method: 'PUT', body: '{"theme":"light","fontSize":"large"}' });
await fetch('/api/settings/theme', { method: 'PUT', body: '{"theme":"dark"}' });
}
2. Error Handling Complexity
Problem: With Promise.all(), a single failure causes the entire operation to fail
Solution: Use Promise.allSettled() or individual try/catch blocks
// Better error handling with Promise.allSettled
async function loadDashboardWithResilience() {
const promises = [
fetch('/api/user').then(res => res.json()),
fetch('/api/posts').then(res => res.json()),
fetch('/api/stats').then(res => res.json())
];
const results = await Promise.allSettled(promises);
return {
user: results[0].status === 'fulfilled' ? results[0].value : null,
posts: results[1].status === 'fulfilled' ? results[1].value : [],
stats: results[2].status === 'fulfilled' ? results[2].value : defaultStats()
};
}
3. Server Overload
Problem: Too many parallel requests can overwhelm your server
Solution: Implement request rate limiting
// Rate-limited API calls
class RateLimitedAPI {
constructor(maxConcurrent = 5) {
this.queue = [];
this.runningCount = 0;
this.maxConcurrent = maxConcurrent;
}
async request(url, options = {}) {
// If we're at the concurrency limit, wait for a slot
if (this.runningCount >= this.maxConcurrent) {
await new Promise(resolve => this.queue.push(resolve));
}
this.runningCount++;
try {
// Make the actual request
return await fetch(url, options);
} finally {
this.runningCount--;
// If there are waiting requests, let one proceed
if (this.queue.length > 0) {
const next = this.queue.shift();
next();
}
}
}
}
// Usage
const api = new RateLimitedAPI(3); // Max 3 concurrent requests
const responses = await Promise.all([
api.request('/api/resource1'),
api.request('/api/resource2'),
api.request('/api/resource3'),
api.request('/api/resource4'), // This will wait until one of the first 3 completes
api.request('/api/resource5') // This will also wait
]);
4. Memory Leaks
Problem: Unhandled promises and abandoned API calls can cause memory leaks
Solution: Always handle promise rejections and implement request cancellation
// Cancellable fetch
function makeCancellableFetch(url, options = {}) {
const controller = new AbortController();
const signal = controller.signal;
const promise = fetch(url, { ...options, signal })
.then(response => response.json());
return {
promise,
cancel: () => controller.abort()
};
}
// Usage
const { promise, cancel } = makeCancellableFetch('/api/long-running-query');
// User navigates away or cancels the operation
if (userCancelled) {
cancel();
}
Conclusion: A Holistic Approach to API Optimization
Reducing API latency requires a thoughtful approach that considers both frontend and backend strategies. By parallelizing independent API calls, you can achieve significant performance improvements without changing your backend code.
To summarize the key strategies:
- Identify API Dependencies: Analyze your API calls to determine which ones can run in parallel.
- Use Promise.all() and Promise.allSettled(): Parallelize independent requests for faster execution.
- Implement Progressive Loading: Display data as it becomes available instead of waiting for all API calls to complete.
- Consider Advanced Techniques: Prefetching, caching, request deduplication, and GraphQL can further improve performance.
When applied correctly, these techniques can reduce perceived latency by 50% or more, significantly enhancing the user experience of your JavaScript applications.
Remember, optimization is an iterative process. Start by measuring your current performance, implement these techniques, and then measure again to quantify the improvements. Your users will appreciate the faster, more responsive application that results.
What API optimization techniques have you found most effective in your projects? Share your experiences in the comments below!