Topics
v6.0.0
On this page · sections

JavaScript Task Scheduler

Can you keep 1,000 fetches from firing at once — without freezing the queue when one of them fails?

Level: Hard · Coding Round · Async Concurrency

Why This Question Matters

  • Frontend apps constantly fire more async work than the network or the tab can take.
  • Interviewers use this to see if you can bound concurrency instead of Promise.all everything.
  • Cleanup on failure is a senior signal: a leaked slot means the queue dies in production.
  • Same core shows up in uploaders, sync jobs, and rate-limited APIs.

Problem

“Implement createScheduler({ concurrency }). add(task) returns a Promise. At most concurrency tasks run at once. A rejected task must not stall the queue.”

const scheduler = createScheduler({ concurrency: 2 }); scheduler.add(() => fetchUser(1)); scheduler.add(() => fetchUser(2)); scheduler.add(() => fetchUser(3));

add(task)

  • task is a function. It may return a value or a Promise.
  • Return a Promise that fulfills with the task result or rejects with the task error.
  • If task is not a function, reject with a TypeError.

Concurrency

  • At most concurrency tasks execute at the same time.
  • Extra tasks wait in a queue and start when a slot frees.
  • concurrency is a positive integer. Reject 0 or non-integers.

Failure

  • If a task rejects, still release the slot and start the next waiting task.
  • The scheduler itself keeps running. Only that add() Promise rejects.

Promise.all

  • This must work: await Promise.all([scheduler.add(a), scheduler.add(b), ...]).
  • Result order is add order, not finish order.

Example

const scheduler = createScheduler({ concurrency: 2, }); const results = await Promise.all([ scheduler.add(() => fetchUser(1)), scheduler.add(() => fetchUser(2)), scheduler.add(() => fetchUser(3)), scheduler.add(() => fetchUser(4)), ]);
Concurrency = 2 A ────────────────✓ B ───────✓ C ───────────────✓ D ────────✓

C and D wait for a free slot.

Mental Model

queue[] active waiting + running <= concurrency
add → queue → slot? → start → active++ ↓ task settles ↓ active-- → runNext()

Solution

Queue + active

Two fields:

const queue = []; let active = 0;

Start while active < concurrency. Never:

active > concurrency

Implementation

function createScheduler({ concurrency = 1 } = {}) { if (!Number.isInteger(concurrency) || concurrency < 1) { throw new Error("concurrency must be a positive integer"); } const queue = []; let active = 0; function runNext() { while (active < concurrency && queue.length > 0) { const { task, resolve, reject } = queue.shift(); active++; Promise.resolve() .then(task) .then(resolve, reject) .finally(() => { active--; runNext(); }); } } function add(task) { if (typeof task !== "function") { return Promise.reject( new TypeError("task must be a function") ); } return new Promise((resolve, reject) => { queue.push({ task, resolve, reject, }); runNext(); }); } return { add, }; }

Why This Works

There are three cases.

Slot free

active = 0, concurrency = 2 add(A) → A starts, active = 1 add(B) → B starts, active = 2

Queue while full

add(C) → queue = [C], active = 2

A finishes → active = 1 → runNext() → C starts.

Task rejects

The caller’s Promise rejects. finally still runs: active--, then runNext(). Other work is not frozen.

Edge Cases

Success-only cleanup

Don’t do:

task().then(() => { active--; runNext(); });

A reject never decrements active. The scheduler stalls.

Release too early

Don’t active-- then await task(). The slot must stay taken while the task is running.

Sync throw

scheduler.add(() => { throw new Error("failed"); });

Promise.resolve().then(task) turns that into a rejection. Direct task() would throw on the scheduler’s stack.

Non-Promise return

scheduler.add(() => 42);

Same path. 42 is Promise-compatible.

concurrency < 1

The scheduler can never start work. Reject it in the constructor.

Promise.all order

Finish order B, C, A. Promise.all still returns [A, B, C].

Scheduler → how many run Promise.all() → result order

Complexity

OperationTimeSpace
addO(1)—
shiftO(n)—
Start taskO(1)—
Queue—O(n)

queue.shift() is O(n). Fine for the interview. Production: a head index or deque.

Use Cases

Bulk API requests

Cap in-flight fetch so 1,000 IDs do not open 1,000 sockets.

Uploads and imports

Only N files in flight; the rest wait in the queue.

Image / thumbnail work

CPU and memory stay bounded while a list processes.

Prefetch

Low-priority fetches share a small pool so they cannot crowd out user-visible requests.

Background sync

A long job list runs steadily without freezing the tab.

Senior Follow-Ups

1. What happens if a task rejects?

Q. Does a failed task freeze the queue?

Answer: No, if cleanup is in finally(). The add() Promise still rejects to the caller.

“A rejected task must not keep a slot. finally releases it and starts the next item.”

2. Why not Promise.all(tasks.map((t) => t()))?

Q. Isn’t that simpler?

Answer: It starts everything at once. That is the bug the scheduler exists to prevent.

“Promise.all is the result API. The scheduler is the concurrency API. Use both.”

3. Why a queue instead of dropping extra work?

Q. Why not reject when full?

Answer: A queue is backpressure on execution. C, D, E wait; when A finishes, C starts. Dropping is a different product choice (maxQueueSize).

4. How would you add retry?

Q. Should the scheduler retry internally?

Answer: Retry belongs inside the task. Sequential attempts — not three parallel copies of the same request.

scheduler.add(() => retry(() => fetchUser(id), { maxAttempts: 3 }) );

“Scheduler = how many tasks. Retry = how many attempts for one task.”

5. Timeout vs cancel?

Q. Does Promise.race with a timer abort fetch?

Answer: No. Race only stops waiting. fetch may still run. Use AbortController when the operation supports it.

scheduler.add(() => withTimeout(fetchUser(id), 3000));

“Timeout is how long we wait. Abort is asking the work to stop.”

6. FIFO vs priority?

Q. How do payments jump the line?

Answer: Store { task, priority } and pick the highest waiter. FIFO is fair. Priority can starve prefetch if payments never stop arriving.

7. How do you bound memory?

Q. Concurrency is 2. The producer adds a million tasks.

Answer: Cap maxQueueSize. Reject, drop oldest, or slow the producer. The active limit does not cap the waiting list.

“Concurrency protects execution. A bounded queue protects memory.”

8. When is the scheduler idle?

Q. Is an empty queue idle?

Answer: No. queue = [] and active = 2 is still running. Idle is active === 0 and queue.length === 0. Recheck before resolving onIdle() if a new add just landed.

9. pause / resume?

Q. How do you stop starting new work?

Answer: A paused flag. runNext returns early. Running tasks finish. resume() calls runNext().

10. How would you make shift O(1)?

Q. Why mention queue.shift()?

Answer: Array shift moves every remaining item. Use a head index and compact occasionally.

Senior Engineer Interview Answer

“I’d keep a FIFO queue and an active counter. add returns a Promise and enqueues the function. We start work while active < concurrency. On settle, finally decrements active and calls runNext(). That is the invariant. Retry, timeout, and abort wrap the task — they are not a second scheduler.”

Interview Checklist

✓ concurrency is a positive integer ✓ add() returns a Promise ✓ active never exceeds concurrency ✓ extra work is queued ✓ slot released on fulfill and reject ✓ queue keeps moving after a failure ✓ Promise.all preserves add order ✓ sync throw becomes a rejection ✓ non-function rejects ✓ shift() cost called out

Scheduler = queue + active never exceeds N. Slot is a resource. finally gives it back.

Last updated on