Mini JavaScript thread pool with maximum number of concurrently running tasks I wrote this mini thread pool in JavaScript that takes a maximum number of concurrently running tasks and runs them in the order it was given. I appreciate any feedback or review. Thank you.
class TaskPool {
  constructor(poolSize) {
    this.poolSize = poolSize;
    this.queue = [];
    this.runningTasks = 0;
    this.scheduled = null;
    this.id = 0;
  }

  backgroundRunner() {
    if (this.queue.length) {
      if (this.runningTasks &lt; this.poolSize) {
        const index = 0;
        const [[task, id]] = this.queue.splice(index, 1);
        this.runningTasks++;
        task(id).finally(() =&gt; {
          this.runningTasks--;
          this.backgroundRunner();
        });
      } else {
        console.log('queue is full');
        if (!this.scheduled) {
          console.log('scheduled for later');
          this.scheduled = setTimeout(() =&gt; {
            this.backgroundRunner();
            this.scheduled = false;
          }, 1000);
        }
      }
    }
  }

  run(task) {
    this.queue.push([task, ++this.id]);
    this.backgroundRunner();
  }
}

Tests:
function task(id) {
  //create an ID
  return new Promise((resolve, _) =&gt; {
    console.log('running task #', id);
    setTimeout(() =&gt; {
      console.log('done running task #', id);
      resolve();
    }, 400);
  });
}

const pool = new TaskPool(3);
for (let i = 0; i &lt; 10; i++) {
  pool.run(task);
}

jsfiddle
Some suggestions:

It's not entirely clear until getting to the bottom of the class what the queue array holds. Maybe call it something more precise like unstartedTasks or unstartedTasksQueue.

You can return early to avoid nested indentation - cutting down on unnecessary indentation can make it clearer exactly which block a } at the end corresponds to. For example, backgroundRunner could be made to be:
backgroundRunner() {
  if (!this.queue.length) {
    return;
  }
  // lots of code


The scheduled for later section looks superfluous since unstarted tasks will be run immediately upon the completion of a task, due to the
task(id).finally(() =&gt; {
  this.runningTasks--;
  this.backgroundRunner();
});


When you want to remove the first element from an array and use it, use shift, not splice. This:
const [[task, id]] = this.queue.splice(index, 1);

can be
const [task, id] = this.queue.shift();


Unless the insertion index of the task needs to be saved, you could simplify the queue by incrementing and using the id not when the task is pushed, but when the task gets executed. This way the queue only needs to be an array of functions, not an array of [task, id]s.


See below for the version I'd prefer, if a class-based implementation is required. I use # to clearly distinguish what's exclusively private methods and properties from a public method:


class TaskPool {
  #unstartedTasksQueue = [];
  #runningTaskCount = 0;
  #id = 0;
  #runningTaskLimit;
  constructor(runningTaskLimit) {
    this.#runningTaskLimit = runningTaskLimit;
  }
  insertTask(fn) {
    if (this.#runningTaskCount === this.#runningTaskLimit) {
      this.#unstartedTasksQueue.push(task);
    } else {
      this.#startTask(fn);
    }
  }
  #startTask(fn) {
    this.#runningTaskCount++;
    fn(this.#id++).finally(() =&gt; {
      if (this.#unstartedTasksQueue.length) {
        this.#startTask(this.#unstartedTasksQueue.shift());
      } else {
        this.#runningTaskCount--;
      }
    });
  }
}

function task(id) {
  //create an ID
  return new Promise((resolve) =&gt; {
    console.log('running task #', id);
    setTimeout(() =&gt; {
      console.log('done running task #', id);
      resolve();
    }, 4000);
  });
}

const pool = new TaskPool(3);
for (let i = 0; i &lt; 5; i++) {
  pool.insertTask(task);
}



But all the private methods and properties, despite making the code's intent a bit clearer, can look a bit ugly. Since the external user of the class is only interfacing with 2 things (the constructor, and the run method), I'd prefer to make a function instead of a class, which returns the run (or, as I've named it, insertTask) method, so as to rely on the closure instead of on #s:


const makeTaskPool = (runningTaskLimit) =&gt; {
  const unstartedTasksQueue = [];
  let runningTaskCount = 0;
  let id = 0;
  const startTask = (fn) =&gt; {
    runningTaskCount++;
    fn(id++).finally(() =&gt; {
      if (unstartedTasksQueue.length) {
        startTask(unstartedTasksQueue.shift());
      } else {
        runningTaskCount--;
      }
    });
  };
  return (fn) =&gt; {
    if (runningTaskCount === runningTaskLimit) {
      unstartedTasksQueue.push(task);
    } else {
      startTask(fn);
    }
  };
};

function task(id) {
  //create an ID
  return new Promise((resolve) =&gt; {
    console.log('running task #', id);
    setTimeout(() =&gt; {
      console.log('done running task #', id);
      resolve();
    }, 4000);
  });
}

const insertTask = makeTaskPool(3);
for (let i = 0; i &lt; 5; i++) {
  insertTask(task);
}