On this page
Async Task Queue with Pause and Resume
Last reviewed 22 Sept 2026
Problem
Implement a TaskQueue class. Tasks (functions returning promises) can be added at any time; at most concurrency run at once; each add returns a promise for that task’s result. The queue can be paused and resumed:
const q = new TaskQueue({ concurrency: 2 });const a = q.add(() => upload(file1)); // starts nowconst b = q.add(() => upload(file2)); // starts nowconst c = q.add(() => upload(file3)); // waits for a free slotq.pause(); // running tasks finish; queued ones waitq.resume();await q.onIdle(); // everything doneClarifying questions
- Does
pause()cancel running tasks? No — it only stops new ones from starting. - Does one task’s failure affect others? No — only that task’s promise rejects.
clear(): drop queued tasks — reject their promises or leave them pending? Reject, so callers are not left waiting forever.onIdle(): when nothing is queued and nothing is running.
Approach
Keep a FIFO array of { task, resolve, reject }, a count of running tasks, and a paused flag. A single #next() method starts tasks while there is a free slot, the queue is not paused and something is waiting. Every finished task frees a slot and calls #next() again. onIdle promises are resolved whenever the queue drains.
Step-by-step build
Step 1 — add and run with a limit
class TaskQueue { #queue = []; #running = 0; constructor({ concurrency = 1 } = {}) { this.concurrency = concurrency; }
add(task) { return new Promise((resolve, reject) => { this.#queue.push({ task, resolve, reject }); this.#next(); }); }
#next() { while (this.#running < this.concurrency && this.#queue.length) { const { task, resolve, reject } = this.#queue.shift(); this.#running++; Promise.resolve().then(task).then(resolve, reject).finally(() => { this.#running--; this.#next(); }); } }}Step 2 — pause and resume
pause() { this.paused = true; }resume() { this.paused = false; this.#next(); }// in #next: while (!this.paused && ...)Step 3 — onIdle and clear
onIdle() { if (this.#running === 0 && this.#queue.length === 0) return Promise.resolve(); return new Promise((resolve) => this.#idleWaiters.push(resolve));}Call the waiters at the end of #next when both counts are zero.
Final code
class TaskQueue { #queue = []; #running = 0; #idleWaiters = [];
constructor({ concurrency = 1, autoStart = true } = {}) { if (!Number.isInteger(concurrency) || concurrency < 1) throw new RangeError('concurrency must be >= 1'); this.concurrency = concurrency; this.paused = !autoStart; }
get size() { return this.#queue.length; } // waiting get pending() { return this.#running; } // running
add(task) { return new Promise((resolve, reject) => { this.#queue.push({ task, resolve, reject }); this.#next(); }); }
pause() { this.paused = true; }
resume() { if (!this.paused) return; this.paused = false; this.#next(); }
clear(reason = new Error('Task cleared from queue')) { const dropped = this.#queue.splice(0); dropped.forEach(({ reject }) => reject(reason)); this.#checkIdle(); }
onIdle() { if (this.#running === 0 && this.#queue.length === 0) return Promise.resolve(); return new Promise((resolve) => this.#idleWaiters.push(resolve)); }
#next() { while (!this.paused && this.#running < this.concurrency && this.#queue.length) { const { task, resolve, reject } = this.#queue.shift(); this.#running++; Promise.resolve() .then(task) .then(resolve, reject) .finally(() => { this.#running--; this.#next(); }); } this.#checkIdle(); }
#checkIdle() { if (this.#running === 0 && this.#queue.length === 0 && this.#idleWaiters.length) { this.#idleWaiters.splice(0).forEach((resolve) => resolve()); } }}Edge cases
- Adding while paused queues the task; it starts on
resume(). - A task that throws synchronously rejects only its own promise —
Promise.resolve().then(task)catches it. onIdle()on an empty queue resolves immediately; while paused with queued tasks it waits until they run.clear()rejects queued tasks but lets running ones finish.- Raising
concurrencyat runtime should start more tasks — call#next()from a setter if that is required.
Follow-ups
- Priorities: insert by priority instead of
push(or use a heap for large queues). - Per-task timeout: wrap each task with withTimeout.
- Events: emit
active,completed,error,idle— extend the EventEmitter. - Where is this used? Upload managers, crawlers, background job runners, rate-sensitive API clients.
Common mistakes
pause()that tries to abort running tasks without cancellation support.- Forgetting to call
#next()after a task finishes, so the queue stalls after the firstconcurrencytasks. onIdlethat resolves when the queue is empty but tasks are still running.- A failed task that stops the whole queue.
Related
- Previous: promise pool — the same slot logic for a fixed list.