Queueable Progress Bars
Spawn progress bars that fill over a duration and queue so only N run at once. Timers, cancellation, and the bar that must not jump when dequeued.
- machine-coding
- interview
- javascript
- async
The trap is treating the bar as CSS. You click Add, a div transitions width from 0 to 100% over two seconds, and the fourth click starts animating immediately while three are already running. Or worse: the fourth bar is born at 40% because you measured progress from createdAt, which includes time spent waiting.
Queueable progress bars are a concurrency pool with a progress UI. At most limit jobs run. The rest sit at 0% until a slot opens. Width comes from startedAt, not from enqueue time. That is the same shape as mapLimit, drawn as bars.
Key takeaways
- Jobs:
queued | running | done. CaprunningatN.- Record
startedAtwhen a slot is taken. Queued bars stay at 0.- Width is
(now - startedAt) / duration, clamped to 100, then frozen.- CSS
transition: width 2son a bar that already waited in the DOM will look right only if you start that transition when it becomesrunning.- Cancel removes the job or marks it aborted so it does not take a future slot.
A queue of jobs, a concurrency cap, a width percentage
Vanilla or React, the model is the same. Each job is an id, a status, and timestamps. A scheduler promotes from the queue whenever running < limit. A clock (interval or requestAnimationFrame) rereads Date.now() and writes percentages.
type Job = {
id: string;
status: "queued" | "running" | "done" | "canceled";
createdAt: number;
startedAt: number | null;
duration: number;
};
function percent(job: Job, now: number) {
if (job.status === "queued" || job.status === "canceled") return 0;
if (!job.startedAt) return 0;
const p = ((now - job.startedAt) / job.duration) * 100;
return Math.min(100, Math.max(0, p));
}
createdAt is for display order. It is not progress. If you use it, a bar that waited 800ms on a 2000ms duration jumps to 40% the moment it dequeues. Interviewers watch for that jump.
Start time vs remaining is the other phrasing. Once running, you can store remaining and subtract dt on each tick. That helps pause. For the default prompt, startedAt plus duration is less state.
Promotion is a worker pool, not “chunk the array into batches of N and Promise.all each chunk.” A 100ms job should not hold a slot while a 2000ms sibling in the same batch finishes. Same argument as mapLimit.
Problem statement
Build a panel: an Add button spawns a progress bar that fills to 100% over a fixed duration (about 2s). At most N bars (often 3) animate at once. Extra bars wait in line at 0%, then start from 0 when a slot frees. Optional cancel.
Requirements
Must have
- Add job appends a bar
- At most
limitbars running - Queued bars render at 0% and keep their place
- Running bars fill over
durationusing elapsed time - Completed bars stay at 100%
- No jump from 0 to a mid value when a queued bar starts
Should have
- Configurable
limitandduration - Cancel a queued or running bar
- Count of running / queued
Nice to have
- Pause / resume (remaining time)
- Different durations per job
- Persist completed history
Planning (5 minutes out loud)
- Job record with
statusandstartedAt: number | null - Promote function: while running < limit, start the oldest queued
- Clock that ticks and marks
donewhen elapsed ≥ duration - MVP: add, cap, fill, complete
- Then cancel and a live region for “bar started”
Do not animate with CSS alone until you can say when the transition class is applied.
Architecture
ProgressBoard
├── AddButton
├── JobRow × jobs
│ ├── Bar (width %)
│ └── Cancel
└── scheduler (promote + tick)
API
type ProgressBoardProps = {
limit?: number;
duration?: number;
};
Implementation sketch
const LIMIT = 3;
const DURATION = 2000;
function ProgressBoard() {
const [jobs, setJobs] = useState<Job[]>([]);
const jobsRef = useRef(jobs);
jobsRef.current = jobs;
function promote(list: Job[], now: number) {
const running = list.filter((j) => j.status === "running").length;
let slots = LIMIT - running;
return list.map((j) => {
if (slots > 0 && j.status === "queued") {
slots -= 1;
return { ...j, status: "running" as const, startedAt: now };
}
return j;
});
}
function add() {
const now = Date.now();
const job: Job = {
id: crypto.randomUUID(),
status: "queued",
createdAt: now,
startedAt: null,
duration: DURATION,
};
setJobs((list) => promote([...list, job], now));
}
function cancel(id: string) {
const now = Date.now();
setJobs((list) =>
promote(
list.map((j) =>
j.id === id && j.status !== "done"
? { ...j, status: "canceled" as const }
: j
),
now
)
);
}
useEffect(() => {
const id = window.setInterval(() => {
const now = Date.now();
setJobs((list) => {
const ticked = list.map((j) => {
if (j.status !== "running" || j.startedAt === null) return j;
if (now - j.startedAt >= j.duration) {
return { ...j, status: "done" as const };
}
return j;
});
return promote(ticked, now);
});
}, 50);
return () => clearInterval(id);
}, []);
const now = Date.now();
return (
<div>
<button type="button" onClick={add}>
Add
</button>
<ul>
{jobs.map((job) => (
<li key={job.id}>
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent(job, now))}
aria-label={`Job ${job.status}`}
>
<div style={{ width: `${percent(job, now)}%` }} />
</div>
{job.status !== "done" && job.status !== "canceled" && (
<button type="button" onClick={() => cancel(job.id)}>
Cancel
</button>
)}
</li>
))}
</ul>
</div>
);
}
A 50ms interval is plenty for a bar. requestAnimationFrame is smoother and more work. Either way, read Date.now(). Do not increment a counter that drifts.
CSS: set width from the percentage. If you prefer a transition, add a class that sets width: 100% and transition: width 2s linear when status becomes running, not when the node mounts. A queued node that already has the class will finish while it is still waiting.
Cancel of a running job must free the slot in the same setState as the status change. If you filter it out later, you stall the queue for one tick. Canceled queued jobs should never receive a startedAt.
Accessibility essentials
| Piece | Approach |
|---|---|
| Bar | role="progressbar" with min/max/now |
| Name | Per-job label, not “progress” five times |
| Add / Cancel | Real buttons |
| Status | Optional live region when a job starts or finishes; do not spam every tick |
Updating aria-valuenow every 50ms is noisy for some AT. Throttle to 10% steps, or announce only start and complete.
Interview scoring
| Signal | What they want |
|---|---|
| Cap | Invariant: running.length <= limit |
| Queue | FIFO start order, not batch-of-N |
| Jump | startedAt on promote, 0% while queued |
| Clock | Wall clock, not a CSS hope |
| Cancel | Frees a slot, does not start later |
| Talk | You connect this to mapLimit / a worker pool |
They may ask you to switch limit from 3 to 1 live. Promotion should drain extra running jobs only by letting them finish, not by killing them, unless they say so.
Footguns
- Progress from
createdAt: jump on dequeue - CSS transition from mount: queued bars finish in the background
- Batch
Promise.allof size N: a short job cannot start until the batch ends setStateinside rAF without canceling on unmountkey={index}when canceling from the middle- Closing over stale
jobsin the interval instead of functional updates
Interview out-loud answer
I would model each bar as a job with queued, running, or done, and a
startedAtthat is null until a worker slot is free. Add always enqueues. A promote step starts the oldest queued jobs untilrunning === limit. Width is elapsed over duration fromstartedAt, so a waiting bar stays at zero and does not jump. A timer readsDate.now(), marks jobs done, and promotes again. Cancel flips status and promotes in the same update so the next job starts immediately. It is mapLimit, with percentages.
Related on this site
- mapLimit: Async Map With a Concurrency Cap
- Toast Notification System
- Polling Metrics Dashboard
- The Event Loop
- useRef for values and DOM