Back to Machine Coding
Machine Coding
easy
mid

How would you build concurrent progress bars for multiple tasks running in parallel?

Each task has its own progress (0–100). Track as `tasks: Task[]` with status (idle, running, done, error). Start all in parallel; update progress via callbacks or intervals; render a row per task with a progress bar. Cancel via AbortController. Cap concurrency with a scheduler if needed. Each progress bar memoized so unrelated updates don't re-render others.

4 min read·~30 min to think through

Spec

  • Show N tasks.
  • Each runs in parallel.
  • Each has its own progress 0–100% updated independently.
  • Each can be canceled.
  • Show overall completion.

Data shape

tsx
type Task = {
  id: string;
  label: string;
  progress: number;    // 0..100
  status: 'idle' | 'running' | 'done' | 'error';
  abort?: AbortController;
};

Implementation

tsx
import { useState, useCallback } from 'react';

function ParallelTasks({ tasks: defs }: { tasks: { id: string; label: string; run: (signal: AbortSignal, onProgress: (p: number) => void) => Promise<void> }[] }) {
  const [tasks, setTasks] = useState<Task[]>(() =>
    defs.map((d) => ({ id: d.id, label: d.label, progress: 0, status: 'idle' })),
  );

  const updateTask = useCallback((id: string, patch: Partial<Task>) => {
    setTasks((prev) => prev.map((t) => (t.id === id ? { ...t, ...patch } : t)));
  }, []);

  async function startAll() {
    await Promise.all(defs.map(async (d) => {
      const ac = new AbortController();
      updateTask(d.id, { status: 'running', abort: ac, progress: 0 });
      try {
        await d.run(ac.signal, (p) => updateTask(d.id, { progress: p }));
        updateTask(d.id, { status: 'done', progress: 100, abort: undefined });
      } catch (err) {
        if (err.name !== 'AbortError') updateTask(d.id, { status: 'error', abort: undefined });
      }
    }));
  }

  function cancel(id: string) {
    const t = tasks.find((x) => x.id === id);
    t?.abort?.abort();
    updateTask(id, { status: 'idle', progress: 0, abort: undefined });
  }

  const overall = tasks.reduce((sum, t) => sum + t.progress, 0) / tasks.length;

  return (
    <div>
      <button onClick={startAll}>Start all</button>
      <p>Overall: {overall.toFixed(0)}%</p>
      <div style={{ background: '#eee', height: 4 }}>
        <div style={{ background: '#3b82f6', height: 4, width: `${overall}%` }} />
      </div>
      {tasks.map((t) => (
        <TaskRow key={t.id} task={t} onCancel={() => cancel(t.id)} />
      ))}
    </div>
  );
}

const TaskRow = React.memo(({ task, onCancel }: { task: Task; onCancel: () => void }) => (
  <div>
    <span>{task.label} — {task.progress}% — {task.status}</span>
    <div style={{ background: '#eee', height: 8 }}>
      <div style={{ background: 'green', height: 8, width: `${task.progress}%` }} />
    </div>
    {task.status === 'running' && <button onClick={onCancel}>Cancel</button>}
  </div>
));

A sample task runner

tsx
function fakeUpload(signal: AbortSignal, onProgress: (p: number) => void) {
  return new Promise<void>((resolve, reject) => {
    let p = 0;
    const id = setInterval(() => {
      if (signal.aborted) { clearInterval(id); return reject(new DOMException('aborted', 'AbortError')); }
      p += 10;
      onProgress(p);
      if (p >= 100) { clearInterval(id); resolve(); }
    }, 200);
  });
}

Memoization

Each TaskRow is React.memo'd. When task A's progress updates, only task A's row re-renders — the others see the same reference (we updated a different element in the tasks array, but their per-task reference changes because we map. Refine by passing a stable reference: use a Map keyed on id.

Better:

tsx
const taskMap = useMemo(() => new Map(tasks.map(t => [t.id, t])), [tasks]);
// Or split each task into its own piece of state via useReducer.

For real fine-grained control: each task gets its own component that owns its progress as local state, signaled by callbacks.

Concurrency cap

If N is huge, cap with a scheduler (see [[implement-an-async-scheduler-with-max-concurrency]]).

Edge cases

  • Cancel mid-run — propagate AbortSignal to the task; task must honor it.
  • Restart — preserve or reset progress per UX.
  • One task errors — others continue; show per-row error.
  • Unmount mid-run — abort all in cleanup.

Interview framing

"State is an array of tasks each with progress + status + AbortController. startAll kicks off Promise.all over the runners; each runner receives an AbortSignal and a progress callback. The callback updates only that task's row in state. Memo each TaskRow so unrelated progress updates don't re-render every row. Compute overall as the average of progresses. Cancel forwards ac.abort() to the task; the runner honors the signal and throws AbortError, which we treat as 'not really an error'. Cap concurrency with a scheduler if N is huge. Cleanup aborts all on unmount."

Follow-up questions

  • How do you cap concurrency?
  • What happens on unmount mid-run?
  • Why does each task row need to be memoized?

Common mistakes

  • Setting all task state in one big useState that re-renders everything.
  • Not propagating AbortSignal.
  • Treating AbortError as a failure.

Performance considerations

  • Memo + stable refs keep per-row re-render scoped. For thousands of tasks, virtualize.

Edge cases

  • Cancel during near-completion.
  • Concurrent restart.
  • Tasks with very different durations.

Real-world examples

  • Upload UIs (Dropbox, Google Drive), data import progress, build dashboards.

Senior engineer discussion

Seniors design for cancellation, per-row memoization, and concurrency caps.

Related questions