FrontendPro

46Implement mapLimit() / throttlePromises() with maximum N concurrent tasks

mediumAbout 35 minutesCodingFreepromisesconcurrencyasync

Problem

Implement mapLimit(items, limit, mapper). It calls the async mapper(item, index) for every item, but never runs more than limit calls at the same time. It resolves with the results in input order.

// Upload 200 photos, at most 4 at a time.
const urls = await mapLimit(photos, 4, (photo) => upload(photo));

Requirements

  • At most limit mapper calls are in flight at any moment, and a new call starts as soon as one finishes.
  • Results keep the input order.
  • Reject as soon as one call fails, and do not start any new calls after that.
  • A synchronous throw in mapper counts as a failure.
  • limit must be at least 1; otherwise reject with a RangeError. An empty input resolves to [].

Try it yourself

Write your solution, run it, then submit to check it against the tests.

solution.jsJavaScript

Press Run or ⌘ Enter to execute your code.

Step by step

Build the solution one idea at a time.

  1. 1

    Validate first

    Reject with a RangeError when !(limit >= 1): that catches 0, negatives and NaN, which would otherwise start nothing and hang forever. Resolve [] for an empty input.

  2. 2

    Share one cursor

    Keep next, the index of the next item to start. Whoever starts a call takes next++, so every item is started exactly once.

  3. 3

    Start `limit` calls

    Call runNext() Math.min(limit, items.length) times. Each call claims an index and starts the mapper.

  4. 4

    When a call finishes, start the next one

    On success, store the result at its index, count it, and call runNext() again. That is what keeps exactly limit calls running until the list runs out.

  5. 5

    Resolve with a counter

    When the number of finished calls equals the number of items, resolve with the results. Do not check next: items can be started but not finished yet.

  6. 6

    Stop on failure

    On the first error, set failed = true and reject. runNext returns early when failed is set, so no new calls start. Calls already running finish, but their results are ignored.

Approach

A fixed number of "slots": each slot runs one call at a time and, when it finishes, claims the next index from a shared cursor. A completion counter decides when to resolve, and a failure flag stops new work.

Solution

function mapLimit(items, limit, mapper) {
  return new Promise((resolve, reject) => {
    const list = Array.from(items);
    if (!(limit >= 1)) {
      reject(new RangeError("limit must be at least 1"));
      return;
    }
    const results = new Array(list.length);
    if (list.length === 0) {
      resolve(results);
      return;
    }

    let next = 0;
    let finished = 0;
    let failed = false;

    function runNext() {
      if (failed || next >= list.length) return;
      const index = next++;
      new Promise((res) => res(mapper(list[index], index))).then(
        (value) => {
          results[index] = value;
          finished++;
          if (finished === list.length) resolve(results);
          else runNext();
        },
        (error) => {
          failed = true;
          reject(error);
        },
      );
    }

    for (let i = 0; i < Math.min(limit, list.length); i++) runNext();
  });
}

Complexity

Time: O(n) bookkeeping; the total wait is roughly the sum of the call times divided by limit. Space: O(n) for the results.

Edge cases

  • limit bigger than the number of items runs everything at once.
  • limit = 1 runs the calls one after another.
  • limit = 0, a negative limit or NaN rejects with a RangeError instead of hanging.
  • After a failure, calls already running finish but nothing new starts.
  • The results keep input order, not completion order.

Follow-up questions

Answer each one out loud first, then reveal the model answer.