Skip to main content
โšก Calmops

Handling Multiple Async Operations in JavaScript

Handling Multiple Async Operations in JavaScript

When building real-world applications, you often need to perform multiple asynchronous operations simultaneously. This article covers advanced techniques for managing concurrent async operations, coordinating multiple promises, and handling complex async workflows.

Introduction

Modern JavaScript applications frequently need to:

  • Fetch data from multiple API endpoints
  • Process multiple files or database queries
  • Coordinate dependent and independent async tasks
  • Handle partial failures in concurrent operations
  • Implement timeout and cancellation patterns

Understanding how to efficiently handle multiple async operations is crucial for building performant, responsive applications.

Promise Combinators

Promise.all() - Wait for All Promises

Promise.all() waits for all promises to resolve or rejects if any promise rejects.

// Basic usage
const promise1 = fetch('/api/users');
const promise2 = fetch('/api/posts');
const promise3 = fetch('/api/comments');

Promise.all([promise1, promise2, promise3])
  .then(responses => {
    console.log('All requests completed');
    return Promise.all(responses.map(r => r.json()));
  })
  .then(data => {
    console.log('Users:', data[0]);
    console.log('Posts:', data[1]);
    console.log('Comments:', data[2]);
  })
  .catch(error => {
    console.error('One or more requests failed:', error);
  });
// With async/await
async function fetchAllData() {
  try {
    const [usersRes, postsRes, commentsRes] = await Promise.all([
      fetch('/api/users'),
      fetch('/api/posts'),
      fetch('/api/comments')
    ]);

    const [users, posts, comments] = await Promise.all([
      usersRes.json(),
      postsRes.json(),
      commentsRes.json()
    ]);

    return { users, posts, comments };
  } catch (error) {
    console.error('Failed to fetch data:', error);
    throw error;
  }
}
// Practical example: Parallel database queries
async function getUserProfile(userId) {
  try {
    const [user, posts, followers] = await Promise.all([
      db.users.findById(userId),
      db.posts.findByUserId(userId),
      db.followers.findByUserId(userId)
    ]);

    return {
      user,
      posts,
      followers,
      postCount: posts.length,
      followerCount: followers.length
    };
  } catch (error) {
    console.error('Error fetching user profile:', error);
    throw error;
  }
}

Key characteristics:

  • Rejects immediately if any promise rejects
  • Returns array in same order as input
  • Efficient for independent operations
  • All-or-nothing semantics

Promise.race() - First to Complete

Promise.race() resolves or rejects with the first promise to settle.

// Basic usage
const fetchPromise = fetch('/api/data');
const timeoutPromise = new Promise((_, reject) =>
  setTimeout(() => reject(new Error('Request timeout')), 5000)
);

Promise.race([fetchPromise, timeoutPromise])
  .then(response => response.json())
  .then(data => console.log('Data:', data))
  .catch(error => console.error('Error:', error));
// Implementing request timeout
function fetchWithTimeout(url, timeout = 5000) {
  const fetchPromise = fetch(url);
  const timeoutPromise = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`Request timeout after ${timeout}ms`)), timeout)
  );

  return Promise.race([fetchPromise, timeoutPromise]);
}

// Usage
fetchWithTimeout('/api/slow-endpoint', 3000)
  .then(response => response.json())
  .catch(error => console.error('Request failed:', error));
// Racing multiple data sources
async function fetchFromFastestSource(urls) {
  const promises = urls.map(url => fetch(url).then(r => r.json()));
  
  try {
    const data = await Promise.race(promises);
    console.log('Got data from fastest source:', data);
    return data;
  } catch (error) {
    console.error('All sources failed:', error);
    throw error;
  }
}

// Usage
const data = await fetchFromFastestSource([
  'https://api1.example.com/data',
  'https://api2.example.com/data',
  'https://api3.example.com/data'
]);

Key characteristics:

  • Returns first settled promise
  • Useful for timeouts and fallbacks
  • Remaining promises continue executing
  • First rejection also rejects

Promise.allSettled() - All Results

Promise.allSettled() waits for all promises to settle and returns their results.

// Basic usage
const promises = [
  Promise.resolve('Success 1'),
  Promise.reject(new Error('Failed')),
  Promise.resolve('Success 2')
];

Promise.allSettled(promises)
  .then(results => {
    results.forEach((result, index) => {
      if (result.status === 'fulfilled') {
        console.log(`Promise ${index} resolved:`, result.value);
      } else {
        console.log(`Promise ${index} rejected:`, result.reason);
      }
    });
  });
// Practical example: Batch API requests with partial failures
async function batchFetchUsers(userIds) {
  const promises = userIds.map(id =>
    fetch(`/api/users/${id}`).then(r => r.json())
  );

  const results = await Promise.allSettled(promises);

  const successful = [];
  const failed = [];

  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      successful.push(result.value);
    } else {
      failed.push({
        userId: userIds[index],
        error: result.reason
      });
    }
  });

  return { successful, failed };
}

// Usage
const { successful, failed } = await batchFetchUsers([1, 2, 3, 4, 5]);
console.log(`Loaded ${successful.length} users, ${failed.length} failed`);
// Handling mixed success/failure scenarios
async function processMultipleFiles(files) {
  const uploadPromises = files.map(file =>
    uploadFile(file).catch(error => ({
      file: file.name,
      error: error.message
    }))
  );

  const results = await Promise.allSettled(uploadPromises);

  const uploads = {
    successful: [],
    failed: []
  };

  results.forEach(result => {
    if (result.status === 'fulfilled') {
      if (result.value.error) {
        uploads.failed.push(result.value);
      } else {
        uploads.successful.push(result.value);
      }
    }
  });

  return uploads;
}

Key characteristics:

  • Never rejects
  • Returns array of status objects
  • Useful for partial success scenarios
  • All promises complete before returning

Promise.any() - First Success

Promise.any() resolves with the first promise to fulfill, or rejects if all reject.

// Basic usage
const promises = [
  Promise.reject(new Error('Failed 1')),
  Promise.reject(new Error('Failed 2')),
  Promise.resolve('Success')
];

Promise.any(promises)
  .then(value => console.log('First success:', value))
  .catch(error => console.error('All failed:', error));
// Practical example: Fallback API endpoints
async function fetchDataWithFallback() {
  const endpoints = [
    'https://primary-api.example.com/data',
    'https://backup-api.example.com/data',
    'https://cache-api.example.com/data'
  ];

  const promises = endpoints.map(url =>
    fetch(url)
      .then(r => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
  );

  try {
    const data = await Promise.any(promises);
    console.log('Data from first successful endpoint:', data);
    return data;
  } catch (error) {
    console.error('All endpoints failed:', error);
    throw error;
  }
}
// Racing multiple data sources with success priority
async function getDataFromAnySource(sources) {
  const promises = sources.map(source =>
    source.fetch()
      .then(data => ({
        source: source.name,
        data,
        timestamp: Date.now()
      }))
  );

  try {
    const result = await Promise.any(promises);
    console.log(`Got data from ${result.source}`);
    return result;
  } catch (error) {
    console.error('No sources available:', error);
    throw error;
  }
}

Key characteristics:

  • Resolves with first fulfilled promise
  • Rejects only if all promises reject
  • Useful for fallback scenarios
  • Ignores rejections until all fail

Sequential vs Concurrent Operations

Sequential Execution

// Sequential: Each operation waits for previous
async function sequentialFetch() {
  const user = await fetch('/api/user').then(r => r.json());
  const posts = await fetch(`/api/users/${user.id}/posts`).then(r => r.json());
  const comments = await fetch(`/api/posts/${posts[0].id}/comments`).then(r => r.json());

  return { user, posts, comments };
}

// Takes: ~3 seconds (if each request is 1 second)

Concurrent Execution

// Concurrent: Independent operations run in parallel
async function concurrentFetch() {
  const [user, posts, comments] = await Promise.all([
    fetch('/api/user').then(r => r.json()),
    fetch('/api/posts').then(r => r.json()),
    fetch('/api/comments').then(r => r.json())
  ]);

  return { user, posts, comments };
}

// Takes: ~1 second (all requests run in parallel)
// Mixed: Some sequential, some concurrent
async function mixedFetch() {
  // First, get user (required for next step)
  const user = await fetch('/api/user').then(r => r.json());

  // Then, fetch user-specific data in parallel
  const [posts, followers] = await Promise.all([
    fetch(`/api/users/${user.id}/posts`).then(r => r.json()),
    fetch(`/api/users/${user.id}/followers`).then(r => r.json())
  ]);

  return { user, posts, followers };
}

Advanced Patterns

Retry with Exponential Backoff

async function retryWithBackoff(fn, maxRetries = 3, baseDelay = 1000) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries - 1) throw error;

      const delay = baseDelay * Math.pow(2, attempt);
      console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// Usage
const data = await retryWithBackoff(
  () => fetch('/api/unreliable-endpoint').then(r => r.json()),
  3,
  1000
);

Timeout Wrapper

function withTimeout(promise, timeoutMs) {
  return Promise.race([
    promise,
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('Operation timeout')), timeoutMs)
    )
  ]);
}

// Usage
try {
  const data = await withTimeout(
    fetch('/api/data').then(r => r.json()),
    5000
  );
} catch (error) {
  console.error('Request failed or timed out:', error);
}

Batch Processing with Concurrency Limit

async function batchWithLimit(items, fn, limit = 3) {
  const results = [];
  const executing = [];

  for (const item of items) {
    const promise = Promise.resolve().then(() => fn(item));
    results.push(promise);

    if (limit <= items.length) {
      executing.push(
        promise.then(() => executing.splice(executing.indexOf(promise), 1))
      );

      if (executing.length >= limit) {
        await Promise.race(executing);
      }
    }
  }

  return Promise.all(results);
}

// Usage
const urls = ['url1', 'url2', 'url3', 'url4', 'url5'];
const results = await batchWithLimit(
  urls,
  url => fetch(url).then(r => r.json()),
  2 // Process 2 at a time
);

Cancellation Pattern

class CancellablePromise {
  constructor(fn) {
    this.cancelled = false;
    this.promise = new Promise((resolve, reject) => {
      fn(
        value => !this.cancelled && resolve(value),
        error => !this.cancelled && reject(error)
      );
    });
  }

  cancel() {
    this.cancelled = true;
  }

  then(onFulfilled, onRejected) {
    return this.promise.then(onFulfilled, onRejected);
  }

  catch(onRejected) {
    return this.promise.catch(onRejected);
  }
}

// Usage
const cancellable = new CancellablePromise((resolve, reject) => {
  const timeout = setTimeout(() => resolve('Done'), 5000);
  this.cancel = () => {
    clearTimeout(timeout);
    reject(new Error('Cancelled'));
  };
});

// Cancel after 2 seconds
setTimeout(() => cancellable.cancel(), 2000);

Practical Examples

Parallel Data Loading

async function loadDashboard(userId) {
  try {
    const [user, stats, notifications, settings] = await Promise.all([
      fetchUser(userId),
      fetchUserStats(userId),
      fetchNotifications(userId),
      fetchUserSettings(userId)
    ]);

    return {
      user,
      stats,
      notifications,
      settings,
      loadedAt: new Date()
    };
  } catch (error) {
    console.error('Failed to load dashboard:', error);
    throw error;
  }
}

Fallback Chain

async function fetchWithFallbacks(primaryUrl, fallbackUrls = []) {
  const allUrls = [primaryUrl, ...fallbackUrls];
  const promises = allUrls.map(url =>
    fetch(url)
      .then(r => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .catch(error => {
        console.warn(`Failed to fetch from ${url}:`, error.message);
        throw error;
      })
  );

  try {
    return await Promise.any(promises);
  } catch (error) {
    console.error('All sources exhausted');
    throw error;
  }
}

Partial Success Handling

async function syncMultipleServices(data) {
  const services = [
    { name: 'analytics', sync: () => syncAnalytics(data) },
    { name: 'cache', sync: () => syncCache(data) },
    { name: 'backup', sync: () => syncBackup(data) }
  ];

  const results = await Promise.allSettled(
    services.map(s => s.sync())
  );

  const report = {
    successful: [],
    failed: []
  };

  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      report.successful.push(services[index].name);
    } else {
      report.failed.push({
        service: services[index].name,
        error: result.reason.message
      });
    }
  });

  return report;
}

Best Practices

  1. Choose the right combinator:

    • Promise.all() for all-or-nothing
    • Promise.race() for timeouts
    • Promise.allSettled() for partial success
    • Promise.any() for fallbacks
  2. Avoid unnecessary nesting:

    // โŒ Bad
    Promise.all([p1, p2]).then(results =>
      Promise.all([p3, p4]).then(more => ...)
    );
    
    // โœ… Good
    const [r1, r2] = await Promise.all([p1, p2]);
    const [r3, r4] = await Promise.all([p3, p4]);
    
  3. Handle errors appropriately:

    // โœ… Good
    try {
      const results = await Promise.all(promises);
    } catch (error) {
      console.error('Operation failed:', error);
    }
    
  4. Limit concurrent operations:

    • Prevent overwhelming servers
    • Use concurrency limits for large batches
    • Monitor resource usage
  5. Use timeouts for external APIs:

    const data = await withTimeout(fetchData(), 5000);
    

Common Mistakes

  1. Forgetting Promise.all() for independent operations:

    // โŒ Slow
    const a = await fetchA();
    const b = await fetchB();
    
    // โœ… Fast
    const [a, b] = await Promise.all([fetchA(), fetchB()]);
    
  2. Not handling partial failures:

    // โŒ Fails completely
    await Promise.all(promises);
    
    // โœ… Handles partial failures
    const results = await Promise.allSettled(promises);
    
  3. Ignoring timeout scenarios:

    // โŒ Can hang indefinitely
    await fetch(url);
    
    // โœ… Has timeout
    await withTimeout(fetch(url), 5000);
    

Summary

Handling multiple async operations efficiently is essential for modern JavaScript development. Key takeaways:

  • Promise.all() for coordinating multiple independent operations
  • Promise.race() for implementing timeouts and first-to-complete patterns
  • Promise.allSettled() for handling partial success scenarios
  • Promise.any() for fallback chains
  • Use concurrency limits for large batches
  • Always implement timeouts for external APIs
  • Choose sequential vs concurrent based on dependencies

Next Steps

Comments