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.alleverything. - 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 mostconcurrencytasks 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)
taskis 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
taskis not a function, reject with aTypeError.
Concurrency
- At most
concurrencytasks execute at the same time. - Extra tasks wait in a queue and start when a slot frees.
concurrencyis a positive integer. Reject0or 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 <= concurrencyadd → queue → slot? → start → active++
↓
task settles
↓
active-- → runNext()Solution
Queue + active
Two fields:
const queue = [];
let active = 0;Start while active < concurrency. Never:
active > concurrencyImplementation
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 = 2Queue while full
add(C) → queue = [C], active = 2A 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 orderComplexity
| Operation | Time | Space |
|---|---|---|
add | O(1) | — |
shift | O(n) | — |
| Start task | O(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.
finallyreleases 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
activecounter.addreturns a Promise and enqueues the function. We start work whileactive < concurrency. On settle,finallydecrementsactiveand callsrunNext(). 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 outScheduler = queue + active never exceeds N. Slot is a resource.
finallygives it back.