Star Rating Widget
intermediateA dependency-free rating component: event delegation, hover preview vs committed state, keyboard support, and the ARIA pattern.
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.
Build
createScheduler({ concurrency }): callersschedule(task, { priority, signal })and get a promise for their task's result. At mostconcurrencytasks 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.
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; },
};
}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 }
);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 0Two 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.
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.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.siftUp/siftDown exercise you can sketch.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.
Promise.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 values — 1 = a strict priority serial queue (series with a brain); Infinity = no scheduling at all; 0 or negative should throw loudly.pump is called on completion and on schedule, so it composes; worth one sentence to show you checked.onIdle()), the event-emitter-meets-withResolvers pattern; p-queue ships exactly this API.finally discipline).AbortSignal.timeout(ms) combined with the caller's signal via AbortSignal.any([...]) — composition, not new machinery (the race-timeout lineage).paused flag gating pump; resume calls pump(). Two lines — the question is whether your architecture makes it two lines.(−priority, seq); push/pop in O(log n) — the one classic-DSA moment in the frontend catalog, alongside LRU.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.
O(1) get/put with least-recently-used eviction — the Map insertion-order trick, and the linked-list version interviewers ask about.