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:

  1. It’s the most straightforward implementation
  2. Code evolves organically, with additional API calls added over time
  3. 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

ImplementationAverage Loading TimeRelative Performance
Sequential1050msBaseline
Parallel380ms2.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:

  1. Identify API Dependencies: Analyze your API calls to determine which ones can run in parallel.
  2. Use Promise.all() and Promise.allSettled(): Parallelize independent requests for faster execution.
  3. Implement Progressive Loading: Display data as it becomes available instead of waiting for all API calls to complete.
  4. 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!