Skip to Content
Course content

198: Practice Exercise: Building a Simple Task Queue with Concurrency Limits

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

I've seen this specific bug more times than I can count in PR reviews. A developer wants to fetch 100 resources from an API, but they know the API will rate-limit them if they send 100 requests at once. They try to implement a "concurrency limit," but they end up writing something that looks like this:

async function runTasks(tasks, limit) {
  let activeCount = 0;
  const results = [];

  tasks.forEach(async (task) => {
    // Wait until we are under the limit
    while (activeCount >= limit) {
      // Just a "pause" loop - this is where it breaks
    }

    activeCount++;
    results.push(await task());
    activeCount--;
  });

  return results;
}

The Infinite Loop and the Async Gap

If you run this, two things happen. First, the while (activeCount >= limit) loop is a "busy-wait" loop. In JavaScript's single-threaded event loop, this will completely freeze the browser or Node.js process. The activeCount will never decrease because the code is stuck in that while loop, preventing any of the await task() promises from ever resolving.

Second, forEach doesn't care about async callbacks. It just fires the function for every item in the array and immediately moves on. It doesn't "wait" for the first task to start before firing the second. Even if the busy-wait loop weren't there, you'd still be launching every single task almost simultaneously.

Using a Worker Pool Pattern

The trick to actually limiting concurrency is to stop thinking about "looping through tasks" and start thinking about "filling available slots." Instead of the tasks controlling the flow, we create a set number of "workers" (essentially just Promise chains) that pull from the task list until it's empty.

Here is how I usually handle this. We use a shared index to keep track of which task is next in line. Each worker is a recursive function that grabs the next available index, executes the task, and then calls itself again.

async function runTasks(tasks, limit) {
  const results = new Array(tasks.length);
  let nextTaskIndex = 0;

  async function worker() {
    while (nextTaskIndex < tasks.length) {
      // Grab the current index and increment it for the next worker
      const index = nextTaskIndex++;
      
      try {
        // Execute the task and store the result at the original index
        results[index] = await tasks[index]();
      } catch (err) {
        results[index] = err;
      }
    }
  }

  // Create a pool of workers based on the limit
  const workers = Array.from({ length: limit }, worker);
  
  // Wait for all workers to exhaust the task list
  await Promise.all(workers);
  return results;
}

This works because we only ever have limit number of worker promises running at once. When one worker finishes a task, it immediately looks for the next one. If the tasks array is empty, the worker's while loop ends, the promise resolves, and Promise.all finally lets us know everything is done.

I prefer this approach over complex queue libraries for simple scripts because it's lightweight and avoids the pitfalls of trying to "pause" a loop. You're not fighting the event loop; you're working with it.




📋 Practical Task

Exercise: Building a Rate-Limited Batch File Processor

You are building a system that simulates processing a large batch of files. Some files take longer to process than others, and you must ensure that no more than 3 files are being "processed" at any given moment to avoid memory exhaustion.

Your Task:

  • Create an array of 10 "task" functions. Each function should return a promise that resolves after a random delay (between 100ms and 1000ms) and returns a string: "Processed file [index]".
  • Implement a processQueue function that accepts the tasks array and a concurrency limit (set this to 3).
  • The processQueue function must ensure that no more than 3 promises are pending at any one time.
  • Log the final array of results only after all tasks have completed.

Validation: To verify your concurrency limit is working, add a console.log("Starting task " + index) at the beginning of each task and a console.log("Finished task " + index) at the end. You should see no more than 3 "Starting" logs before a "Finished" log appears.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.