-
Section 1: Getting Started
-
Section 2: Core Syntax and Types
-
Section 3: Strings and Numbers in Depth
-
Section 4: Control Flow
-
Section 5: Functions
-
Section 6: Objects and Arrays
-
Section 7: Maps, Sets, and Symbols
-
Section 8: Asynchronous JavaScript
-
Section 9: Object-Oriented and Prototypes
-
Section 10: The DOM
-
Section 11: Browser APIs
-
Section 12: Modern JavaScript (ES2015-ES2025)
-
Section 13: Functional Programming Patterns
-
Section 14: Error Handling and Debugging
-
Section 15: Testing
-
Section 16: Accessibility for JavaScript Developers
-
Section 17: Internationalization and Localization
-
Section 18: Performance
-
Section 19: Node.js Fundamentals
-
Section 20: Regular Expressions
-
Section 21: Design Patterns in JavaScript
-
Section 22: Security Basics
-
Section 23: Data Structures and Algorithms in JavaScript
-
Section 24: Practical Projects
-
Section 25: More Advanced Async Patterns
-
Section 26: More Object and Class Practice
-
Section 27: Working with Dates and Internationalization
-
Section 28: Web Components
-
Section 29: More DOM and Browser Practice
-
Section 30: Build Tooling for Vanilla JavaScript
-
Section 31: More Practice Projects
-
Section 32: Interview and Algorithm Practice
-
Section 33: Error Objects (MDN Reference)
-
Section 34: TypedArrays and Binary Data
-
Section 35: Reflection and Metaprogramming (MDN Reference)
-
Section 36: More Global Functions (MDN Reference)
198: Practice Exercise: Building a Simple Task Queue with Concurrency Limits
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
processQueuefunction that accepts the tasks array and a concurrency limit (set this to 3). - The
processQueuefunction 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.
There are no comments for now.