ESC

Type to search the knowledge base.

mapLimit: Async Map With a Concurrency Cap

Map an array through an async iteratee with at most N in flight. Preserve output order, fail fast, and drain the queue without starving later items.

advanced6 min read
  • javascript
  • promises
  • async
  • concurrency

You need to fetch 200 image URLs. Promise.all on the whole list opens 200 connections. A series loop takes the sum of every RTT. The candidate then writes “batches of 8”: slice, await Promise.all, next slice. One 2s URL in the first batch parks seven finished slots until it lands. That is a chopped-batch illusion, not a concurrency cap.

mapLimit maps each item through an async iteratee with at most N promises in flight. When one settles fulfilled, the next waiting index starts immediately. Results still line up with the input, not with finish order. This is the worker-pool question behind upload queues, crawlers, and polite API clients.

  • Cap in-flight work, not batch count. A free slot should pick up the next index the moment it opens.
  • Write results[index] = value. Never push on completion.
  • First iteratee rejection fails the returned promise. Stop launching. In-flight work is not cancelled for free.
  • Empty input fulfills with []. limit < 1 is a RangeError.
  • Slice-then-all waits on the slowest of each window and starves later items.

A worker pool, not a chopped-batch illusion

Fix a pool size N. Launch up to N workers. Each worker pulls the next unused index, awaits iteratee(item, index), stores the value at that index, then pulls again until the list is exhausted. The outer promise fulfills when every index has a value (or rejects on the first iteratee failure).

That is series when N === 1 and unbounded parallel when N >= items.length. The interesting case is the middle.

Why batches waste the cap

// Looks limited. Under-uses N whenever a window has a straggler.
async function mapLimitBatched(items, limit, iteratee) {
  const results = [];
  for (let i = 0; i < items.length; i += limit) {
    const slice = items.slice(i, i + limit);
    const part = await Promise.all(
      slice.map((item, j) => iteratee(item, i + j)),
    );
    results.push(...part);
  }
  return results;
}

Take durations [100, 20, 100] and limit = 2.

Approach Timeline Total
Batches start 100 and 20; wait 100; then start the last 100 ~200
Pool start 100 and 20; at t=20 start the last 100; first 100 overlaps it ~120

Same cap, different utilization. Interviewers who have run this in production will ask you to name that gap. Draw the timeline. Then write the pool.

Procedure

  1. Allocate results = new Array(items.length) so slots exist before anyone finishes.
  2. Reject immediately if limit is not an integer >= 1.
  3. Keep nextIndex and active counts. rejected latches the first error.
  4. launch: while active < limit and nextIndex < length, take index = nextIndex++, increment active, run the iteratee.
  5. On fulfill: results[index] = value, decrement active, launch again.
  6. On reject: set the latch, reject the outer promise, do not launch more.
  7. When nextIndex === length and active === 0, fulfill with results.
function mapLimit(items, limit, iteratee) {
  const cap = Number(limit);
  if (!Number.isInteger(cap) || cap < 1) {
    return Promise.reject(new RangeError('limit must be an integer >= 1'));
  }

  const list = items;
  const len = list.length;
  if (len === 0) return Promise.resolve([]);

  const results = new Array(len);
  let nextIndex = 0;
  let active = 0;
  let settled = false;

  return new Promise((resolve, reject) => {
    const fail = (reason) => {
      if (settled) return;
      settled = true;
      reject(reason);
    };

    const launch = () => {
      if (settled) return;

      while (active < cap && nextIndex < len) {
        const index = nextIndex++;
        active += 1;

        Promise.resolve()
          .then(() => iteratee(list[index], index))
          .then(
            (value) => {
              if (settled) return;
              results[index] = value;
              active -= 1;
              if (nextIndex >= len && active === 0) {
                settled = true;
                resolve(results);
                return;
              }
              launch();
            },
            fail,
          );
      }
    };

    launch();
  });
}
const urls = ['/a', '/b', '/c', '/d'];
const pages = await mapLimit(urls, 2, (url) =>
  fetch(url).then((res) => {
    if (!res.ok) throw new Error(String(res.status));
    return res.text();
  }),
);
// pages[i] belongs to urls[i], even if /c finished first

Promise.resolve().then(() => iteratee(...)) wraps thenables and converts a sync throw into a rejection, matching how Promise.all treats mapper functions.

Workers share nextIndex. That increment is synchronous, on the same turn, so two workers cannot claim the same slot. You do not need a mutex in single-threaded JavaScript for this cursor.

Fail-fast, drain, starve

Fail-fast: the first rejection rejects the outer promise. The settled latch stops further launch calls. Tasks already in flight still run to completion. If you need them to abort, pass an AbortSignal into the iteratee and abort on fail. Join is not cancel. Same rule as AbortController with fetch.

Drain: after a fulfill, the worker immediately tries to pull the next index. Later items are not stuck behind a batch boundary. That is the “do not starve” requirement.

Order: new Array(len) plus index assignment. Completion order is irrelevant. A test that asserts results equals the mapped input in the original sequence will catch the push bug.

Empty lists and a cap larger than n

items.length === 0 resolves [] without launching workers. If limit > length, active never exceeds length; you just run fully parallel. Don’t spawn dummy workers that spin on an empty queue.

Sparse inputs: this implementation walks 0 .. length - 1, so holes call iteratee(undefined, index) and still occupy a slot. Array.prototype.map would skip holes. Say which contract you picked.

mapLimit footguns

  1. await inside the launch loop. If you await iteratee in the while, you only have one in flight. Launch must be fire-and-forget, with the continuation in then.
  2. Closing over index wrong. Capture const index = nextIndex++ per launch. A var i shared across the while will race all workers onto the last index.
  3. Resolving twice. After a reject, a late fulfill must not resolve. The settled flag is the guard.
  4. Non-integer limit. 2.7 plus active < limit is a messy cap. Reject, or Math.floor and document it.
  5. Mutating items while running. The cursor is an index, not a queue copy. Don’t shift the source array from the iteratee.

Interview out loud

“I want a pool of at most N workers pulling from a shared index, not slices of N with Promise.all between windows, because a slow item in a batch idles every free slot. Each worker writes results[index] so output order matches the input. The first rejection fails the map and stops new launches; in-flight work continues unless I pass an AbortSignal. Empty input fulfills with an empty array. limit less than 1 rejects. This sits between series (N = 1) and unbounded parallel (N at least length).”

Further reading

Related guides