RepoPilot

Show me how the concurrency limiter actually queues tasks.

About sindresorhus/p-limit · 2 messages · last activity 8/1/2026

Question

Show me how the concurrency limiter actually queues tasks.

RepoPilot

Now I have the actual queuing implementation. Here's how it works, all in pLimit [index.js:L3-L116]:

1. Setup

A Queue instance and an activeCount counter are created in the closure: const queue = new Queue(); let activeCount = 0; [index.js:L14-L15].

2. Enqueuing a call

Every time you call limit(fn, ...args), the returned generator function creates a Promise and calls enqueue [index.js:L69-L71]:

  • enqueue builds a queueItem holding a reject callback [index.js:L52].
  • It wraps the actual "start running" trigger in new Promise(internalResolve => {...}), stashes internalResolve as queueItem.run, and pushes the item onto queue via queue.enqueue(queueItem) [index.js:L54-L57]. This is deliberate — queuing the resolve function rather than immediately running fn preserves the async execution context (e.g. for AsyncLocalStorage) of the caller.
  • That inner promise is chained with .then(run.bind(undefined, function_, resolve, arguments_)), so once something calls queueItem.run(), the real run function executes [index.js:L58].
  • Finally, if activeCount < concurrency, it immediately calls resumeNext() to try to start something right away [index.js:L60-L62].

3. Dispatching from the queue

resumeNext is the gatekeeper [index.js:L17-L22]:

if (activeCount < concurrency && queue.size > 0) {
	activeCount++;
	queue.dequeue().run();
}

It only pulls one item off the queue (FIFO, via queue.dequeue()) when there's spare concurrency, increments activeCount, and calls that item's .run() — which resolves the earlier internal promise and lets the chained .then(run...) actually invoke the user's function.

4. Running the function

run [index.js:L27-L40] executes function_(...arguments_) wrapped in an async IIFE, immediately resolves the outer promise returned to the caller with that result promise (so callers don't wait for internal bookkeeping), then awaits it (swallowing errors just to avoid unhandled rejections — the real rejection still propagates to the caller via the resolved promise), and finally calls next().

5. Freeing a slot

next [index.js:L24-L26] decrements activeCount-- and calls resumeNext() again, which lets the next queued item run if capacity allows — this is the recursive mechanism that keeps the queue draining one at a time as slots free up.

6. Dynamic concurrency changes

The concurrency setter [index.js:L91-L102] validates and updates concurrency, then schedules a queueMicrotask that loops while (activeCount < concurrency && queue.size > 0) resumeNext(); — so raising the limit immediately drains more queued items, as shown in the test 'change concurrency to bigger/smaller value' [test.js:L305-L339].

7. Clearing the queue

clearQueue [index.js:L79-L89] either just calls queue.clear(), or, if rejectOnClear was set, dequeues everything and calls .reject(abortError) on each pending item using an AbortSignal.abort().reason.

This design (queuing a resolver rather than a runnable task) is what lets pendingCount simply report queue.size [index.js:L75-L77] and activeCount track in-flight function calls [index.js:L72-L74].

FOLLOW_UPS:

  • What data structure does the internal Queue class use?
  • How does rejectOnClear produce the AbortError?
  • How is async execution context preserved across queued tasks?

Want to ask your own question?

Open chat for sindresorhus/p-limit

Public Q&A. Generated by RepoPilot from the actual source of sindresorhus/p-limit. AI answers can be incomplete or stale — verify before relying on them.