ESC

Type to search the knowledge base.

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.

intermediate7 min read
  • 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. Cap running at N.
  • Record startedAt when a slot is taken. Queued bars stay at 0.
  • Width is (now - startedAt) / duration, clamped to 100, then frozen.
  • CSS transition: width 2s on a bar that already waited in the DOM will look right only if you start that transition when it becomes running.
  • 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 limit bars running
  • Queued bars render at 0% and keep their place
  • Running bars fill over duration using elapsed time
  • Completed bars stay at 100%
  • No jump from 0 to a mid value when a queued bar starts

Should have

  • Configurable limit and duration
  • 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)

  1. Job record with status and startedAt: number | null
  2. Promote function: while running < limit, start the oldest queued
  3. Clock that ticks and marks done when elapsed ≥ duration
  4. MVP: add, cap, fill, complete
  5. 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

  1. Progress from createdAt: jump on dequeue
  2. CSS transition from mount: queued bars finish in the background
  3. Batch Promise.all of size N: a short job cannot start until the batch ends
  4. setState inside rAF without canceling on unmount
  5. key={index} when canceling from the middle
  6. Closing over stale jobs in 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 startedAt that is null until a worker slot is free. Add always enqueues. A promote step starts the oldest queued jobs until running === limit. Width is elapsed over duration from startedAt, so a waiting bar stays at zero and does not jump. A timer reads Date.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.

Further reading