Skip to Content
Course content

195: Queueing and Rate-Limiting Async Operations

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Imagine you're building a tool that needs to sync a thousand user profiles from a third-party API. In a perfect world, you'd just map over your array of IDs and call await Promise.all(requests). But in the real world, APIs have rate limits. If you hit a server with 1,000 concurrent requests, you're not going to get your data—you're going to get a 429 "Too Many Requests" error, or worse, a temporary IP ban.

We need a way to say: "I have 1,000 things to do, but I only want 3 of them happening at any single moment."

The "Fire and Forget" disaster

Let's start by simulating a fragile API. I'll write a function that mimics a network request but throws an error if we try to run too many of them at once. I'll use a global counter to keep track of "active" requests.

const activeRequests = { count: 0 };

async function simulatedApiCall(id) {
  activeRequests.count++;
  
  // Our fake API crashes if more than 3 requests are active
  if (activeRequests.count > 3) {
    throw new Error(`Rate limit exceeded! Request ${id} failed.`);
  }

  console.log(`Processing request ${id}...`);
  await new Promise(resolve => setTimeout(resolve, Math.random() * 1000));
  
  activeRequests.count--;
  return `Data for ${id}`;
}

Now, here is where I usually make my first mistake when I'm rushing a prototype. I might try to just use a for...of loop with await inside it. That works, but it's incredibly slow because it's sequential. We don't want one-at-a-time; we want three-at-a-time.

If I try Promise.all(ids.map(id => simulatedApiCall(id))), it fails instantly because it launches all 1,000 requests simultaneously. We need a middle ground: a concurrency-limited queue.

Designing the worker pool

The most reliable way to handle this is to treat our tasks as a queue and create a set of "workers" that pull from that queue until it's empty. I like this approach because it separates the list of work from the execution logic.

First, I'll put all my IDs into an array that I can shift() from. Then, I'll create a worker function that keeps calling itself as long as there are tasks left.

async function runWithLimit(limit, items, taskFn) {
  const queue = [...items];
  const results = [];

  async function worker() {
    while (queue.length > 0) {
      const item = queue.shift();
      try {
        const result = await taskFn(item);
        results.push(result);
      } catch (err) {
        results.push(err); // Capture errors so the whole queue doesn't die
      }
    }
  }

  // Fire up the initial set of workers
  const workers = Array.from({ length: limit }, worker);
  await Promise.all(workers);
  return results;
}

Correcting the results order

I just noticed a problem. Because the API calls take different amounts of time (thanks to that Math.random()), results.push(result) will store the data in the order they finished, not the order they were started. If the order of your data matters—which it usually does—this is a bug.

To fix this, I need to track the original index of the item. Instead of just shifting the value, I'll store the index and use that to place the result in the correct slot of a pre-allocated array.

async function runWithLimit(limit, items, taskFn) {
  const queue = items.map((item, index) => ({ item, index }));
  const results = new Array(items.length);

  async function worker() {
    while (queue.length > 0) {
      const { item, index } = queue.shift();
      try {
        results[index] = await taskFn(item);
      } catch (err) {
        results[index] = err;
      }
    }
  }

  const workers = Array.from({ length: limit }, worker);
  await Promise.all(workers);
  return results;
}

Putting it all together

Now we have a robust utility. We can pass in our simulated API call, specify a limit of 3, and handle a huge list of IDs without triggering the rate limit or losing the order of our data.

const userIds = Array.from({ length: 20 }, (_, i) => i + 1);

runWithLimit(3, userIds, simulatedApiCall)
  .then(data => console.log("All tasks finished:", data))
  .catch(err => console.error("Fatal error:", err));

This pattern is the backbone of almost every professional scraping or data-migration script I've ever written. It gives you a "dial" to turn—if the API is feeling slow, you turn the limit down to 2; if they upgrade your plan, you bump it to 10.




📋 Practical Task

Build a Concurrent Image-Downloader Simulator

You are building a tool that simulates downloading a batch of high-resolution images. The "server" you are hitting has a strict limit: it can only handle 2 concurrent downloads. If a 3rd download starts before one of the first two finishes, the server crashes.

Requirements:

  • Create a simulateDownload(imageName) function that returns a promise. It should use setTimeout to resolve after a random time (between 500ms and 1500ms).
  • Implement a downloadManager(images, limit) function. This function should take an array of image names (e.g., ['img1.jpg', 'img2.jpg', ...]) and a concurrency limit.
  • Ensure that no more than 2 downloads are happening at the same time.
  • The final output must be an array of "Downloaded: [imageName]" strings, and they must remain in the same order as the original input array, regardless of which download finished first.
  • Test your manager with a list of 10 images.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.