advancedCommon6 min read · Updated Jul 19, 2026

Build a Priority Task Scheduler with Concurrency Limits

Build an async task scheduler in JavaScript: priority queue + concurrency cap + per-task cancellation and timeouts — the promise pool upgraded to an API-design round.


The problem

Build createScheduler({ concurrency }): callers schedule(task, { priority, signal }) and get a promise for their task's result. At most concurrency tasks run at once; waiting tasks start in priority order; queued tasks can be cancelled before they ever run.

This is the promise pool promoted to a design round: the pool's mechanics are assumed, and the grading moves to API shape, priority semantics, and cancellation — the three axes real schedulers (React's, Node's p-queue, every upload manager) actually differ on. If the pool question checks can you control concurrency, this one checks can you design the thing other engineers will hold.

Implementation

Built from parts this catalog already established: the deferred pattern (Promise.withResolvers), the slot-release discipline (finally), and AbortSignal plumbing.

function createScheduler({ concurrency = 4 } = {}) {
  let running = 0;
  let seq = 0;                 // tie-breaker: FIFO within equal priority
  const queue = [];            // waiting entries, kept sorted (see note)
 
  function enqueue(entry) {
    // Higher priority first; equal priority → earlier seq first (stable FIFO).
    // O(n) insertion — honest and fine for UI-scale queues; see "heap" below.
    const i = queue.findIndex(
      (e) => e.priority < entry.priority ||
             (e.priority === entry.priority && e.seq > entry.seq)
    );
    i === -1 ? queue.push(entry) : queue.splice(i, 0, entry);
  }
 
  function pump() {
    while (running < concurrency && queue.length > 0) {
      const entry = queue.shift();
      if (entry.signal?.aborted) continue; // cancelled while waiting: skip silently
                                           // (its promise was already rejected)
      running += 1;
      // Promise.resolve().then(task) routes sync throws into the rejection path
      Promise.resolve()
        .then(() => entry.task({ signal: entry.signal }))
        .then(entry.resolve, entry.reject)
        .finally(() => {
          running -= 1;
          pump();                          // a slot freed → maybe start the next
        });
    }
  }
 
  return {
    schedule(task, { priority = 0, signal } = {}) {
      const { promise, resolve, reject } = Promise.withResolvers();
      const entry = { task, priority, signal, resolve, reject, seq: seq++ };
 
      if (signal?.aborted) {
        reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
        return promise;
      }
      // Cancel-while-QUEUED: reject immediately and let pump() skip the corpse
      signal?.addEventListener(
        "abort",
        () => reject(signal.reason ?? new DOMException("Aborted", "AbortError")),
        { once: true }
      );
 
      enqueue(entry);
      pump();
      return promise;
    },
 
    get pending() { return queue.length; },
    get active()  { return running; },
  };
}

Usage

const scheduler = createScheduler({ concurrency: 2 });
 
// Thumbnails: low priority, cancellable when the row scrolls away
const rowController = new AbortController();
scheduler.schedule(({ signal }) => fetch(thumbUrl, { signal }), {
  priority: 0,
  signal: rowController.signal,
});
 
// The user just clicked — jump the queue:
const data = await scheduler.schedule(
  ({ signal }) => fetch("/api/details", { signal }),
  { priority: 10 }
);

Dry run (concurrency 1)

schedule(A, p0) → queue [A] → pump: A runs
schedule(B, p0) → queue [B]           (A occupies the slot)
schedule(C, p5) → queue [C, B]        ← priority insertion, C ahead of B
abort(B)        → B's promise rejects NOW (AbortError), corpse stays queued
A finishes      → pump: shift C (p5) → runs; later shift B → aborted? skip
C finishes      → queue empty, running 0

Two behaviors in that trace are the design content: priority orders the queue, never preempts the running task (preemption isn't a thing for arbitrary async work — you can only not-start things), and cancel-while-queued settles the caller immediately rather than making them wait for a slot to learn their task died.

The design decisions that are the actual interview

  • Tasks are functions, promises are receipts. The caller hands over the ability to start work and receives a per-task promise — the same contract as the pool's p-limit form, and the only shape that lets a scheduler exist at all (a promise is already running).
  • Stable FIFO within a priority — without the seq tie-breaker, equal-priority tasks run in whatever order the sort leaves them; two clicks on "load comments" completing in reverse is a bug report. Stability is one integer; know why it's there.
  • Starvation is a real policy question. A stream of high-priority tasks postpones priority-0 work forever. Fixes: priority aging (bump waiting tasks over time) or weighted picking. You don't have to implement it — naming starvation unprompted is the senior checkbox on this question, same family as the retry thundering herd.
  • Cancellation has two distinct phases — queued (scheduler's job: reject + skip) and running (the task's job: honor the signal you thread into it — the scheduler can't stop work it doesn't own; cancellation is cooperative). Muddling the two phases is the most common design error.
  • Sorted array vs heap — O(n) insert is honest and right for hundreds of tasks; a binary heap gives O(log n) for thousands. Saying "array now, heap when profiling says so, same interface" is exactly the right altitude — and if they want the heap, it's a siftUp/siftDown exercise you can sketch.

Where you see it in production

Upload managers (N parallel chunks, user-visible files above background sync), image/prefetch pipelines (viewport thumbnails over offscreen ones — pair with the infinite-scroll controller), rate-limited API clients, and — the marquee example — React's scheduler: user-input lanes preempt transition lanes, which is this exact idea with time-slicing added. Browsers ship a native cousin: scheduler.postTask(fn, { priority: "user-blocking" | "user-visible" | "background", signal }) — knowing postTask exists (and that it schedules main-thread work, while yours schedules async work) places you firmly in 2026.

Edge cases interviewers probe

  • Cancel while queued vs while running — the two-phase story; expect both timelines.
  • Sync-throwing tasksPromise.resolve().then(task) keeps the slot accounting correct (the finally still runs); a raw task() throw would skip running -= 1 and leak a slot forever — the deadlock-by-bookkeeping bug.
  • concurrency edge values1 = a strict priority serial queue (series with a brain); Infinity = no scheduling at all; 0 or negative should throw loudly.
  • Re-entrant scheduling — a task scheduling more tasks mid-run: pump is called on completion and on schedule, so it composes; worth one sentence to show you checked.
  • Draining — "wait until everything finishes": track a counter + a deferred that resolves at zero (onIdle()), the event-emitter-meets-withResolvers pattern; p-queue ships exactly this API.

Common mistakes

  • Accepting promises instead of task functions (nothing is actually scheduled).
  • Priority implemented as sort-on-every-pump (O(n log n) per start) or, worse, unstable ordering within a priority.
  • Cancellation that only works for running tasks — queued callers hang forever on a promise nobody will settle.
  • Slot leak on sync throw (the finally discipline).
  • No answer for starvation when asked "what if high-priority tasks never stop coming?"

Follow-up questions

  • "Add per-task timeout."AbortSignal.timeout(ms) combined with the caller's signal via AbortSignal.any([...]) — composition, not new machinery (the race-timeout lineage).
  • "Add retry for failed tasks." — wrap inside the task (retrying holds the slot, bounded pressure) vs re-scheduling (releases the slot, loses queue position) — the retry-inside-the-pool argument resurfacing as an API choice.
  • "Pause/resume the whole scheduler." — a paused flag gating pump; resume calls pump(). Two lines — the question is whether your architecture makes it two lines.
  • "Implement the heap." — array-backed binary heap keyed on (−priority, seq); push/pop in O(log n) — the one classic-DSA moment in the frontend catalog, alongside LRU.
  • "How does React's lane model relate?" — priorities as bitmasks, work-loop time-slicing via interruptible traversal, and starvation handled by expiration (aging by another name) — your scheduler's concepts, industrial-strength.

  • Star Rating Widget

    intermediate

    A dependency-free rating component: event delegation, hover preview vs committed state, keyboard support, and the ARIA pattern.

  • IntersectionObserver, in-flight guards, end-of-data states, and the sentinel pattern — the pagination question every feed team asks.

  • Debounce, cancellation, and stale-response guards composed into a search box that never shows the wrong results.

  • LRU Cache

    advanced

    O(1) get/put with least-recently-used eviction — the Map insertion-order trick, and the linked-list version interviewers ask about.