Design a throttled progress bar queue
Clicking Add appends a progress bar, but only K bars may run at once and the rest wait in line. The toy hides a real component: the upload tray in Google Drive, a batch image resizer, a bulk email sender. The hard parts are a state model the scheduler and the UI agree on, a scheduler that keeps exactly K slots busy through pause, cancel, retry and live changes to K, progress that animates smoothly and honestly even in a background tab, and hundreds of rows that render without jank.
The question
Build a button labelled Add. Each click appends a progress bar that fills from 0% to 100% in 2 seconds. At most 3 bars may be filling at the same time; any extra bars wait, empty, until a slot frees up.
Then make it real: the bars stand for actual work, like uploading files or resizing images. Walk me through the state, the scheduler, how progress is animated and reported, pause, cancel and retry, how it stays fast with hundreds of items, and how a screen reader user follows along.
Real-life examples: The upload tray in Google Drive and Dropbox, A batch image resizer that processes 200 photos, 4 at a time, A bulk email sender's per-recipient progress list, Package managers that download N packages in parallel, The classic "progress bars" front end interview question and its follow-ups
Requirements: from a toy to an upload tray
The interview often starts as a toy. An Add button appends a bar, every bar fills in 2 seconds, and only 3 may fill at once. A junior answer stops at a setTimeout per bar.
The real question is what the toy stands for: a task queue with a concurrency limit, where the bars are the visible face of real jobs. The concurrency limit, K, is how many jobs may run at the same time. The same design shows up in the upload tray of Google Drive, a tool that resizes 200 photos 4 at a time, and a bulk email sender that shows progress per recipient.
Nearby guides already cover parts of this, so we link to them instead of repeating them. The file uploader guide owns chunked and resumable uploads. The Google Drive guide shows the upload tray inside the full product, and the toast notifications guide owns status toasts.
The countdown timer guide explains timer drift in depth, and server rate limits and saving the queue across reloads belong to a rate-limited queue. This guide is about the queue itself: its state, its scheduler, its progress and its rendering.
Start with questions. Each answer changes the design, and the middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| Is the work simulated or real? | Both: the toy fills over N seconds; the real version uploads files or runs jobs | The scheduler must not care; a run(task, signal, report) function hides the difference |
| How many may run at once? | K = 3 by default, changeable while running (1 to 10) | A scheduler that reacts to K changing, not a fixed pool |
| Which order do waiting items start in? | First in, first out, with an optional priority | A plain array or a priority queue |
| Can the user pause, cancel or retry? | Yes, per item and for the whole queue | An explicit state machine per item; AbortController for real work |
| How many items at most? | Up to about 1,000 in one session (a photo folder) | Batched rendering and a virtualized list above a threshold |
| Do we know each item's size? | Usually (files), sometimes not (a server job) | Determinate and indeterminate bars; overall progress weighted by bytes |
| Must it survive a reload? | No; we warn before leaving while work runs | No persistence in this design |
Functional requirements
- Add an item at any time. It starts at once if fewer than K items are running, or waits in the queue.
- Show each item's status (queued, running, paused, done, failed, cancelled) and progress from 0% to 100%.
- Show an overall bar and a summary like "4 of 10 done, 2 failed".
- Pause and resume one item or the whole queue; cancel one item or all; retry a failed item.
- Change the concurrency limit K while the queue runs.
- Show an estimated time remaining once there is enough data to make it honest.
Non-functional requirements
- Correct under pressure: never more than K running, never a slot left idle while items wait, no item started twice. These are the bugs interviewers poke at.
- Smooth: bars animate at the display's frame rate on a mid-range laptop, and 300 visible rows stay under the 16.7 ms frame budget of a 60 Hz screen.
- Honest: a bar never moves backwards except on retry, and a background tab shows the right progress the moment it comes back.
- Accessible: every bar has a name and a value for screen readers, completion and failures are announced once, and every action works from the keyboard.
- Testable: the queue logic runs without a DOM, so unit tests with fake timers cover it in milliseconds.
Out of scope: the upload protocol itself (chunks, resumable sessions, checksums), server rate limits, and saving the queue to IndexedDB. Say so out loud: naming what you leave out, and where it would go, is a senior signal.
Check your understanding · Exercise 1
Which answers from the interviewer would change the design of the queue itself, not just its styling? Select all that apply.
What makes this hard
Picture the queue as a waiting line in front of K service desks. Items join the back of the line, and when a desk frees up, the item at the front walks over. Each desk reports how far along its item is. Every hard part comes from one of four facts about this picture.
- It is concurrent. Several items change state at nearly the same moment. Two finish in the same tick; the user clicks Cancel while an item is finishing; K drops from 3 to 1 while 3 are running. If the scheduler and the UI each keep their own idea of who is running, they soon disagree; one source of truth, changed by one pure function, is the fix (chapter 3).
- It is long-lived. A 2 GB folder takes many minutes. The user switches tabs, the laptop sleeps, the network drops. Browsers slow timers in hidden tabs and stop animation frames entirely, so progress must come from timestamps or real byte counts, never from counted ticks (chapter 5).
- It is interruptible. Pause, resume, cancel and retry can each happen in any state. Real work (an upload, a
fetch) must be stopped for real, not just hidden, or it keeps using the network and may still finish on the server. That meansAbortControllerand an explicit state machine (chapters 3 and 4). - It is chatty. An upload reports progress up to about every 50 ms (the XHR standard's rate limit), so three uploads send about 60 events a second, and hundreds of rows re-rendering per event will miss frames. Rendering must be batched to the screen's own pace (chapter 7).
Keep these four in mind. When you are stuck in the interview, ask which one a question touches: "What if two items finish at once?" is concurrency, "What if the tab is hidden?" is long-lived, "What does Cancel actually stop?" is interruptible, and "What about 500 files?" is chatty.
Check your understanding · Exercise 2
K is 3. Three items are running and two are queued. One running item finishes. Which outcomes break the invariant? Select all that apply.
The state model: one list, one reducer, one machine per item
The naive build gives every bar its own component state: a useState for progress and a setTimeout loop inside each ProgressBar. The parent keeps a runningCount and, when a bar calls onDone, starts the next one. It works in the demo.
Then two bars finish in the same tick, both onDone handlers read runningCount as 3 from the same render, both set it to 2, and one completion is lost for good. Or a bar is removed while its timer still fires, and a finished item calls onDone twice. The bug is structural: the truth about who is running is split across many components and a counter that copies it.
Decision Where should the queue's state live, and who is allowed to change it?
What we pick: Pick the pure reducer. It turns the race in the naive build into something that cannot happen: "who starts next" is computed from the whole state inside one function call, not from a counter that each handler reads separately. Wrap it in a small store (chapter 8) so React and non-React code read the same snapshot.
The shape of an item
type Status = "queued" | "running" | "paused" | "done" | "failed" | "cancelled";
type Item = {
id: string;
label: string; // "beach-042.jpg"
status: Status;
progress: number; // 0..1, the last known value
total: number | null; // bytes or units; null means unknown size
duration: number | null; // ms, simulated items only
startedAt: number | null;// performance.now() when the current run began
elapsed: number; // ms already run before the current run (kept on pause)
attempts: number; // runs started, resumes included; tags each run's actions
failures: number; // failed runs so far, checked against the retry limit
retryAt: number | null; // an automatic retry may not start before this time
priority: number; // higher starts first; 0 by default
error: string | null;
};
type QueueState = {
items: Record<string, Item>;
order: string[]; // the line, oldest first
concurrency: number; // K
paused: boolean; // the whole queue
};
Two fields deserve a word. startedAt and elapsed together let us compute simulated progress from the clock: progress is (elapsed + now - startedAt) / duration. Pausing adds the current run to elapsed and clears startedAt, so resuming continues where it stopped instead of from zero.
And there is no runningCount field. Counts like running, queued, done and failed are computed from items with a selector, a small function that derives a value from the state. A stored count is a copy, and copies drift; that was the naive bug.
Each item is a small state machine
A state machine lists the states an item can be in and the only events that move it between them. Anything not listed is ignored. This is what makes a late succeed from an already cancelled upload harmless: cancelled has no succeed transition, so the reducer returns the state unchanged. The state machines lesson covers the idea in general; here is the machine for one item.
| From | Event | To | Note |
|---|---|---|---|
| queued | a slot frees up (fill) | running | Only the reducer's fill step does this |
| running | progress | running | Ignored if it would move progress backwards |
| running | succeed | done | Terminal |
| running | fail | failed | Or back to queued with a retryAt delay, if automatic retries remain |
| running | pause | paused | Frees the slot; work is aborted or suspended |
| paused | resume | queued | Not straight to running: it must wait for a free slot |
| queued, running, paused | cancel | cancelled | Real work is aborted |
| failed, cancelled | retry | queued | Progress resets to 0; the failure count is kept |
Note the resume row. Resuming to queued instead of running is a small choice with a big effect: if K items are running and the user resumes a fourth, it waits its turn instead of pushing the count to K + 1. The machine keeps the invariant for us.
The reducer tops up the running set
function reducer(state: QueueState, action: Action): QueueState {
return fill(transition(state, action), action.now);
}
function fill(state: QueueState, now: number): QueueState {
if (state.paused) return state;
const ids = state.order;
let running = ids.filter((id) => state.items[id].status === "running").length;
if (running >= state.concurrency) return state;
const items = { ...state.items };
for (const id of pickOrder(ids, items)) { // FIFO or priority, chapter 4
if (running >= state.concurrency) break;
const item = items[id];
if (item.status !== "queued" || (item.retryAt !== null && item.retryAt > now)) continue;
items[id] = { ...item, status: "running", startedAt: now, retryAt: null, attempts: item.attempts + 1 };
running += 1;
}
return { ...state, items };
}
The reducer only decides. It never calls fetch or starts a timer. A separate runner watches each new state, compares it with the previous one, and acts on the difference: items that became running get their work started; items that stopped being running get their AbortController aborted. This split is the same one React uses between rendering and effects, and it is why the scheduler and the UI can never disagree: they read the same object.
Check your understanding · Exercise 3
Why does this design store startedAt and elapsed instead of only progress for a simulated bar? Select all that apply.
The scheduler: keep exactly K slots busy
The scheduler answers one question: when does the next item start? The most common first answer is batching: take the first K items, await Promise.all(batch), then take the next K. It looks tidy and it wastes time.
Say K = 3 and the first batch holds files that take 1 s, 1 s and 6 s. Two slots finish after 1 second and then sit idle for 5 seconds, because the batch waits for its slowest member. With 30 files of mixed sizes, batching often takes a third longer or more, and the more uneven the sizes, the worse it gets. What we want is a sliding window: whenever any slot frees up, the next item starts at once.
Decision How should the scheduler start the next item?
What we pick: Fill on every state change. It is a sliding window by construction, it treats K as data rather than as a number of running loops, and it reuses the state machine for pause, cancel and retry. A scan of 1,000 items costs well under a millisecond, and progress actions can skip fill entirely because they never free a slot.
The runner turns state into work
const controllers = new Map<string, AbortController>();
store.subscribe(() => {
const { items } = store.getSnapshot();
for (const [id, controller] of controllers) {
if (items[id]?.status !== "running") { controller.abort(); controllers.delete(id); }
}
for (const item of Object.values(items)) {
if (item.status !== "running" || controllers.has(item.id)) continue;
const controller = new AbortController();
const attempt = item.attempts;
controllers.set(item.id, controller);
const send = (type: string, extra = {}) =>
store.dispatch({ type, id: item.id, attempt, now: performance.now(), ...extra });
run(item, controller.signal, (progress) => send("progress", { progress }))
.then(() => send("succeed"))
.catch((error) => { if (!controller.signal.aborted) send("fail", { error: String(error) }); })
.finally(() => { if (controllers.get(item.id) === controller) controllers.delete(item.id); });
}
});
Every action carries the attempt number it belongs to, and the reducer drops actions whose attempt is not the item's current one. This guards against a subtle bug: attempt 1 is cancelled and retried, but its run ignored the abort signal and resolves late. Without the check, that stale success would mark attempt 2 as done while attempt 2 is still uploading. The request races lesson uses the same pattern so that a slow old response cannot overwrite a newer one.
Order: first in, first out, or priority
First in, first out (FIFO) is the default because it matches what users expect: the file they dropped first starts first. Priority helps in two real cases. A user clicks "Upload next" on one waiting file, so it should jump the line. Or the product starts small files first so the user sees early wins and a shared link works sooner.
pickOrder sorts waiting items by priority, highest first, and by position in order within the same priority, so equal items stay FIFO. The risk is starvation: a large low-priority file never starts while small files keep arriving. If arrivals can be endless, add aging: raise an item's priority a little for every few seconds it waits.
Changing K while the queue runs
Raising K is easy: setConcurrency updates the number and fill runs in the same reducer call, so the new slots fill at once. Lowering K raises a choice. We could abort the youngest running items to get down to the new K right away, but that throws away work: a 90% uploaded file would start again. The better default lets running items finish and starts nothing new until running drops below the new K.
Pausing the queue and pausing one item
"Pause" means two different things, and the interviewer will check that you know it. Pausing one item moves it from running to paused. Its slot is now free, so fill starts the next queued item; that is usually what the user wants ("do this one later").
Pausing the queue sets paused: true, which stops fill from starting anything. Running items then either finish (a soft pause, or "drain") or are paused too (a hard pause). Users who press Pause all on uploads usually mean "stop using my network now", so we pick the hard pause. Resumable uploads, covered in the file uploader guide, keep that from meaning a restart.
For real work, pause and cancel both end in controller.abort(). You pass controller.signal to fetch, or listen for its abort event and call xhr.abort(), and the request stops. The difference is what the state keeps: cancel moves the item to cancelled, while pause keeps elapsed and any server upload offset so resume can continue. Without real cancellation, a "cancelled" upload keeps using bandwidth and may even land on the server, which is a correctness bug, not just waste.
abort(). The late reply is harmless because the state machine has no way from cancelled to done.Retry: how many times, and where in the line
| Kind of retry | Where it goes | Why |
|---|---|---|
| Automatic, after a network error | Back of the line, after a delay | A file that fails every time must not block the head of the line. Retry 2 or 3 times with exponential backoff, then mark it failed |
| Automatic, after a server says the input is bad | Nowhere: fail at once | A 400 or a corrupt image will fail again; retrying wastes a slot |
| Manual, the user clicks Retry | Front of the line | The user is looking at it and expects it to start soon; put it first among items of the same priority |
| Retry all failed | Back of the line, in their old order | Many items at once: keep fairness with the items already waiting |
Backoff timing, jitter and which errors count as temporary belong to the retries and backoff lesson. The queue's own job is smaller: count failures, refuse to retry beyond the limit, and decide where the item rejoins the line. The delay lives in the state as retryAt: fill skips the item until then, and the runner sets one setTimeout that dispatches a wake action at that time so fill runs again.
0.0 s: empty queue, K = 3
- 0.0 s: empty queue, K = 3
Check your understanding · Exercise 4
K is 4 and 4 items are running, each about half done, with 10 waiting. The user lowers K to 2. What should happen? Select all that apply.
Check your understanding · Exercise 5
An item fails because the network dropped. It has failed once, and the queue gives up after 3 failed runs. Which choices are sound? Select all that apply.
Animating progress: time is the truth, frames are a view
For the simulated bar, the naive answer is a setInterval every 100 ms that adds 1% to progress in state. A 10 second bar takes 100 ticks. It looks fine until the user switches tabs, because browsers run timers in hidden pages about once per second at most (Chrome exempts a page that played sound in the last 30 seconds).
Chrome goes further with intensive throttling: once a page has been hidden for 5 minutes and silent for 30 seconds, chained timers (an interval, or a timeout set from inside another timer's callback) run only once a minute. Our interval now adds 1% per second instead of 10%. If the user hides the tab at 2 seconds and comes back at 8, the bar should show 80% but shows 26%: 20 ticks while visible, then 6 while hidden.
(now - startedAt) / duration is right on the first frame back.The fix is the same idea as in chapter 3: never count ticks, compute from timestamps. Progress is (elapsed + now - startedAt) / duration. Whatever wakes us up, a timer, a frame or a returning tab, reads the clock and gets the right answer. The remaining question is how to paint that value smoothly, and that is a question about the browser's rendering pipeline.
Decision How should a bar's fill be animated?
What we pick: Use one requestAnimationFrame loop that reads progress from timestamps and writes transform: scaleX(). It keeps the clock in the state, where the scheduler can see it, and it never triggers layout. Use width only if you must support a layout where the fill's size affects other content, which a progress bar never needs.
const fills = new Map<string, HTMLElement>(); // registered by each rendered row's ref
function paint(now: number) {
const { items } = store.getSnapshot();
for (const [id, fill] of fills) {
const item = items[id];
if (!item) continue;
const p =
item.status === "done" ? 1
: item.duration === null ? item.progress // real work: the last reported value
: (item.elapsed + (item.startedAt === null ? 0 : now - item.startedAt)) / item.duration;
fill.style.transform = `scaleX(${Math.min(1, p)})`;
}
frame = requestAnimationFrame(paint);
}
let frame = requestAnimationFrame(paint);
The now that rAF passes in uses the same clock as performance.now(), so it compares directly with startedAt. Simulated bars read the clock; real work has no duration, so its bar shows the last progress value that reached the store. Only rendered rows register a fill, so with a virtualized list this is a few dozen style writes per frame.
| Approach | Work per frame | Hidden tab | Pause and resume |
|---|---|---|---|
Count ticks with setInterval | A React render per tick | Drifts: ticks slow to once a second, then once a minute | Easy, but the value is already wrong |
Transition width | Layout and paint for each bar | Correct when it returns | Hard: read the computed width back |
Transition transform: scaleX() | Composite only | Correct when it returns | Hard, as above |
| rAF from timestamps | One callback, composite only | No work while hidden; correct on the first frame back | Free: the state already handles it |
Advance time, hide the tab for a while, then show it again.
The reflow, repaint and composite lesson shows the pipeline behind the table: changing width sends the browser back to layout, while transform and opacity changes can skip straight to compositing. With one bar the difference is invisible. With 300 bars on a mid-range phone, layout for every bar on every frame can take a large share of a 16.7 ms frame; the lab above counts the layouts a single bar costs.
Check your understanding · Exercise 6
A 10 second simulated bar starts. After 2 seconds the user switches tabs and comes back at the 8 second mark. Which approaches show about 80% on the first frame back? Select all that apply.
Real work: where progress numbers come from
Now swap the simulation for real work. The queue does not change at all: it calls run(item, signal, report) and waits for the promise. What changes is where report gets its numbers: the clock for a simulated bar, the browser's network events for an upload, and the server itself for a server job such as resizing images. Each source has its own quirks, and an honest bar has to respect them.
Decision Which browser API should report upload progress?
What we pick: Use XMLHttpRequest for uploads. It is the only option that gives real upload progress in every major browser in 2026. Wrap it in the same run(item, signal, report) shape so the queue never knows which API is behind it, and switch to fetch later if request streaming becomes universal.
function uploadRun(item: UploadItem, signal: AbortSignal, report: (p: number | null) => void) {
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/uploads");
xhr.upload.onprogress = (e) => report(e.lengthComputable ? e.loaded / e.total : null);
xhr.upload.onload = () => report(1); // all bytes sent: the row says "Processing"
xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(`HTTP ${xhr.status}`)));
xhr.onerror = () => reject(new Error("network"));
xhr.onabort = () => reject(new DOMException("Aborted", "AbortError"));
signal.addEventListener("abort", () => xhr.abort(), { once: true });
xhr.send(item.file);
});
}
report(null) means "size unknown, show an indeterminate bar".Notice the gap between xhr.upload.onload and xhr.onload. The first fires when the last byte has left the browser. The second fires when the server answers, which can take seconds if it scans for viruses or builds thumbnails. A bar that reads 100% for 4 seconds and then turns green looks frozen, so show a separate phase instead: at 100% of bytes, switch the label to "Processing" and the bar to indeterminate until the response arrives.
Determinate or indeterminate
A determinate bar shows how much is done. An indeterminate bar only says "working". Use indeterminate whenever you don't know the total: lengthComputable is false, the server is processing, or a server job reports no steps. Never fake a determinate bar with made-up numbers that crawl toward 90%: users learn that the number lies.
In HTML, a progress element without a value attribute is indeterminate, and an element with role="progressbar" and no aria-valuenow means the same to a screen reader.
Overall progress: by bytes or by count
The tray header shows one bar for the whole queue, and the obvious formula is items done divided by items total. Here is how that lies. Ten files: nine photos of 1 MB each are done, and one 991 MB video has just started. By count the bar says 90%; by bytes it says 9 of 1,000 MB, under 1%, and the real wait is many minutes.
- When sizes are known (files), weight by bytes: the sum of
progress * totalover all items, divided by the sum oftotal. Show the count next to it in words: "9 of 10 files". - When sizes are unknown (server jobs), weight by count, because there is nothing better, and say so with the words "3 of 10 done" rather than a percentage that implies precision.
- When some sizes are unknown, give unknown items the average size of the known ones. It is a guess, so round the overall percentage to whole numbers.
- Cancelled items leave the total. Failed items stay in it until retried or dismissed, so the overall bar does not reach 100% while something still needs the user's attention.
Smoothing jumpy progress, without lying
Real progress is lumpy. An upload can jump ahead in its first moment, because the operating system's send buffer accepts the first chunk at once, and then crawl at the real network speed. A server job may report 0%, 0%, 70%, 100%. Two rules keep the bar honest and calm.
First, the value only moves forward: the reducer keeps Math.max(previous, next) for the same attempt, so a late or reordered event never pulls the bar back; only a retry resets it. Second, smooth the movement, not the value: when a new value arrives, let the frame loop ease the drawn fill toward it over about 200 ms. The state always holds the true number; only the picture glides.
Check your understanding · Exercise 7
An upload's bytes are all sent, but the server takes 5 seconds to answer. What should the row show during those 5 seconds? Select all that apply.
The upload tray, part by part
The upload tray is where these numbers meet the user. It sits in a corner, collapsed to one line while the user keeps working, and expands to the full list on demand. The Google Drive guide shows it inside the whole product; here is the component itself, part by part.
- Header: the summary in words ("4 of 10 done, 1 failed"), the overall bar, time left, and Pause all, Cancel all, and collapse buttons.
- List: one row per item with its name, size, bar, status text and its own actions. Running items first, then failed ones that need attention, then queued, then done. A status change moves a row only between groups, never within the running group, so rows do not jump under the cursor.
- Cancel all asks for confirmation when it would throw away a lot of work (say more than 30 seconds of transfer). A one-click mistake that discards 2 GB of uploads is costly; a confirm on a 2 second job is just friction. Aborted requests cannot be taken back, so this is a confirmation, not an undo; the undo and redo guide weighs the two for actions with effects outside the app.
- When everything settles, the header turns into "9 uploads complete, 1 failed" with Retry failed and Close. A toast is optional and belongs to the toast system; see the toast notifications guide.
Estimated time remaining, and why it jumps
Time left is remaining bytes divided by throughput, and throughput is the hard part. Measured over the last second, it swings wildly: Wi-Fi hiccups, the K slots change as items finish, and a burst of small files spends more time on request overhead than on bytes. An estimate that jumps from 40 seconds to 6 minutes and back is worse than none.
Decision How should the tray estimate time remaining?
What we pick: Use an exponentially weighted moving average of bytes per second, sampled once a second, and present it humbly: hide it for the first few seconds and while paused, and round it to "about 2 minutes" or "less than a minute". Precision you don't have reads as a promise you will break.
function createEta(alpha = 0.2, minSamples = 5) {
let rate = 0, samples = 0, lastBytes = 0, lastTime: number | null = null;
return (doneBytes: number, remainingBytes: number, now: number): number | null => {
if (lastTime !== null && now > lastTime) {
const instant = (doneBytes - lastBytes) / ((now - lastTime) / 1000);
rate = samples === 0 ? instant : alpha * instant + (1 - alpha) * rate;
samples += 1;
}
lastBytes = doneBytes;
lastTime = now;
if (samples < minSamples || rate <= 0) return null;
return remainingBytes / rate;
};
}
When the user tries to leave
Closing the tab kills every running upload. Add a beforeunload listener only while something is running or queued, and remove it as soon as the queue is idle. Browsers show their own generic "Leave site?" text, ignore custom messages, and only show it at all if the user has interacted with the page.
Keeping the listener permanently is a mistake: it warns people for no reason, and Firefox, at the time of writing, will not put a page with a beforeunload listener into the back/forward cache, so back navigation gets slower. Navigation inside a single-page app does not fire beforeunload at all. That is one more reason the queue lives at app level: the tray stays on screen while the user moves between routes.
Errors, one row at a time
A failed item says why in plain words ("Network error", "File too large: limit is 5 GB", "Server rejected the file type"), and offers Retry only when retrying could help. A file that is too large will be too large again, so it gets Remove instead. The header's Retry failed button retries every retryable failure in its original order. Errors never block the queue: a failed item frees its slot, and the next one starts.
Check your understanding · Exercise 8
Which tray behaviours are sound? Select all that apply.
Rendering hundreds of bars without dropping frames
Take a batch image tool with 300 photos in the list and K = 10. Each running item reports progress up to 20 times a second, so the store can receive 200 progress events a second. The naive build dispatches each event, creates a new state, and re-renders all 300 rows if they are not memoized: 60,000 row renders a second.
If a row render costs a few hundredths of a millisecond on a mid-range laptop (an estimate; the animation lab in chapter 5 assumes 0.05 ms), that is over a second of work for every second of wall time. It cannot keep up, and the bars stutter while the page ignores clicks.
React 18 batches state updates that happen in the same task, including inside promises and timeouts. That does not help here: each XHR progress event is its own task, so React sees 200 separate updates. Batching across events is our job, and the CSV import guide meets the same problem from a web worker by posting progress at most every 100 ms.
Decision How often should progress reach the store and the screen?
What we pick: Coalesce progress per animation frame. Progress is only worth showing when the screen can show it, and one batched action per frame turns 200 render passes into at most 60. Status changes (succeed, fail) are different: dispatch them at once, because the scheduler needs them to start the next item, and rAF does not run in a hidden tab.
Rows that only render when their item changes
Batching reduces how often the store changes; memoization reduces what renders when it does. Because the reducer copies only the items that changed and keeps every other item object as it was, a row can subscribe to its own item and skip rendering whenever that object is the same. With useSyncExternalStore, React calls every row's snapshot function on each change, but a lookup by id costs almost nothing, and only rows whose item changed render.
function useItem(queue: Queue, id: string) {
return useSyncExternalStore(queue.subscribe, () => queue.getSnapshot().items[id]);
}
const Row = memo(function Row({ queue, id }: { queue: Queue; id: string }) {
const item = useItem(queue, id);
const percent = Math.floor(item.progress * 100);
return (
<li className="row" data-status={item.status}>
<span id={`label-${id}`}>{item.label}</span>
<div role="progressbar" aria-labelledby={`label-${id}`}
aria-valuenow={item.total === null ? undefined : percent} aria-valuemin={0} aria-valuemax={100}>
<div className="fill" ref={(el) => queue.registerFill(id, el)} />
</div>
<RowActions queue={queue} item={item} />
</li>
);
});
Fewer rows, fewer layouts
- Virtualize above a threshold. An upload tray with 12 files needs nothing. A list of 1,000 photos does: render only the rows in view plus a few above and below, about 20 to 30 rows in all. The virtualization lesson and the virtualized list guide cover the mechanics; the threshold, around 100 to 200 rows, is an estimate to confirm with a profile on your slowest target device.
- Let the frame loop paint the fill. The fill's
transformis written by the single frame loop from chapter 5, not by React. React renders the row for status, the label and the percentage text; the fill moves every frame without a render. - Update the visible number less often than the bar. A percentage that changes 60 times a second is unreadable. Round it, and let the row select the rounded number and the status rather than the whole item, so it renders only when its text would change; the bar carries the motion.
- Never read layout inside the loop. Reading
offsetWidthafter writing a style forces the browser to run layout right then, once per row: that is layout thrash. Write transforms only, and if you must measure, measure once per frame before any writes.
| Approach (300 rows, 10 running) | Store updates per second | Row renders per second |
|---|---|---|
| Per event, rows not memoized | about 200 | about 60,000 |
| Per event, memoized rows | about 200 | about 200, plus 300 cheap memo or snapshot checks per update |
| Per frame, memoized rows | at most 60 | at most 600 (10 changed rows per frame) |
| Per frame, frame loop paints fills, rows select the rounded percent | at most 60 | one per row each time its whole percent changes, plus status changes |
Check your understanding · Exercise 9
Progress is coalesced per animation frame. The user hides the tab while 5 uploads run. Which statements are true? Select all that apply.
Architecture: a headless core, its API, and its tests
We now have all the parts; the architecture question is where to draw the lines between them. Everything that decides (state, scheduling, retries, counts) lives in a headless core, meaning code with no DOM and no React. Everything that shows (bars, buttons, announcements) lives in a thin UI that reads the core's snapshots. Real work plugs in through one function shape, run(item, signal, report).
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Queue store | Holds QueueState; applies actions with the pure reducer; subscribe and getSnapshot | One source of truth; testable as plain functions |
| Runner | Starts work for newly running items; aborts work for items that left running; one AbortController per attempt | Keeps side effects out of the reducer |
| Run adapters | Simulated timer, XHR upload, server job; each turns work into report calls and a promise | The queue never knows what the work is |
| Progress batcher | Keeps the latest progress per item; flushes once per frame as one action | Protects the store and React from event storms |
| Frame painter | One rAF loop writing transform: scaleX() to registered fill elements | Motion without React renders |
| Selectors | Counts, overall progress by bytes, ETA | Derived, never stored, so never stale |
| UI: tray, list, row | Renders status, labels, actions; virtualizes long lists | Replaceable per product; no scheduling logic |
| Announcer and leave guard | Polite live region for milestones; beforeunload while work runs | Page-level concerns, one instance per page |
The batcher sits outside the reducer because the reducer must stay pure and synchronous: it cannot wait for a frame. The painter sits outside React because a value that changes every 16 ms is animation, not UI state. There is one runner, instead of each row starting its own work, because rows come and go with virtualization: a row that scrolls out of view unmounts, and work tied to its lifetime would be cancelled by scrolling. The queue outlives any one row, so it must live above all of them.
How data flows, end to end
Follow one file from drop to done. The tray dispatches add; the reducer appends the file and fill marks it running if a slot is free. The runner sees a new running item, creates an AbortController, and calls the upload adapter, whose XMLHttpRequest sends bytes to the server. Progress events go to the batcher, which flushes once per frame into the store; the row updates its text, and the painter moves its fill.
The server answers 201, the adapter resolves, and the runner dispatches succeed at once. The reducer marks the file done and, in the same call, starts the next queued file. The announcer notices the new done count and, when the batch finishes, says so.
Check your understanding · Exercise 10
A teammate proposes starting each upload inside the row component's useEffect, and aborting it in the effect's cleanup. What goes wrong? Select all that apply.
The API: createQueue, a hook, and ready-made components
Teams will reuse this queue for uploads, image exports and bulk sends, each with a different look. The API question is how much the component should own.
Decision What should other teams import?
What we pick: Ship the headless queue with ready-made components on top. The same core then serves the upload tray, the image tool and the email sender. Most teams will use the default components; the few who need a new look keep the scheduling for free.
type Run<T> = (task: T, signal: AbortSignal, report: (p: number | null) => void) => Promise<void>;
type QueueOptions<T> = {
concurrency: number; // K, 1 to 10
run: Run<T>;
maxFailures?: number; // give up after this many failed runs, default 3
retryDelay?: (attempt: number) => number; // ms before an automatic retry
isRetryable?: (error: unknown) => boolean; // network errors yes, 4xx no
now?: () => number; // injectable clock, default performance.now
};
interface Queue<T> {
add(task: T, meta: { label: string; total?: number; priority?: number }): string;
pause(id?: string): void;
resume(id?: string): void;
cancel(id?: string): void;
retry(id?: string): void; // no id: every failed item
setConcurrency(k: number): void;
remove(id: string): void; // settled items only
subscribe(listener: () => void): () => void;
getSnapshot(): QueueState;
onIdle(): Promise<void>; // resolves when nothing runs or waits
}
Two details make this pleasant to use. The task itself (a File, an image, a recipient) is kept in a private Map keyed by id, not inside QueueState. The state stays plain, serializable data that can be logged, shown in dev tools, or saved later. And getSnapshot returns the same object until something changes, which is what useSyncExternalStore requires: a new object on every call would make React re-render forever.
export const uploads = createQueue<File>({ concurrency: 3, run: uploadRun });
export function useQueue<T>(queue: Queue<T>) {
return useSyncExternalStore(queue.subscribe, queue.getSnapshot);
}
function DropZone() {
const onDrop = (files: FileList) => {
for (const file of files) uploads.add(file, { label: file.name, total: file.size });
};
return <FilePicker onFiles={onDrop} />;
}
// anywhere else in the app
<ProgressTray queue={uploads} title="Uploads" />
Testing with fake timers and an injected clock
Because the core has no DOM, the important tests are fast unit tests. Use your test runner's fake timers (a mode where the test, not the clock, decides when setTimeout callbacks fire), and pass a now function so time is fully in the test's hands. Test the invariant from chapter 2 after every step, not just the final result. The unit and integration tests lesson covers fake timers in general.
test("keeps exactly K running and fills a free slot at once", async () => {
vi.useFakeTimers();
let t = 0;
const queue = createQueue<number>({
concurrency: 2,
now: () => t,
run: () => new Promise((resolve) => setTimeout(resolve, 1000)),
});
for (let i = 0; i < 5; i++) queue.add(i, { label: `bar ${i}` });
expect(counts(queue.getSnapshot())).toMatchObject({ running: 2, queued: 3 });
t = 1000;
await vi.advanceTimersByTimeAsync(1000);
expect(counts(queue.getSnapshot())).toMatchObject({ running: 2, queued: 1, done: 2 });
queue.setConcurrency(3);
expect(counts(queue.getSnapshot())).toMatchObject({ running: 3, queued: 0 });
});
Check your understanding · Exercise 11
Which choices make the queue easy to test without a browser? Select all that apply.
Accessibility: values, names, and quiet announcements
A progress bar is a picture of a number. A screen reader user needs the number, the name of what it measures, and to hear about the moments that matter, without hearing every percent. Get any of these wrong and the tray is either silent or exhausting.
Decision Should each bar be a native progress element or an element with the progressbar role?
What we pick: Use the native progress element by default; it is correct with no effort. Switch to role="progressbar" when the bars need the frame painter and custom styling, as ours do, and cover the attributes with a unit test so nobody drops one.
Names and values that make sense out loud
- Each row's bar is named by its label:
aria-labelledbypointing at the file name, so it reads "beach-042.jpg, progress bar, 45%". - The overall bar gets
aria-valuetextthat says what a sighted user reads from the header: "45%, 4 of 10 files done, about 2 minutes left". A bare "45%" leaves out the most useful part. - Status is never colour alone. A failed row has an icon and the words "Failed: network error"; a paused row says "Paused".
- The "Processing" phase from chapter 6 removes
aria-valuenow, so the bar reads as busy rather than stuck at 100%, and the row's status text says "Processing" in words.
Announce milestones, not percentages
The tempting mistake is aria-live on each bar. A live region is an element whose changes a screen reader reads out on its own, and with 3 bars updating 4 times a second it would talk without pause. Instead, keep one polite live region (role="status") for the whole tray and send it only milestones: an item failed, the queue paused, the whole batch finished ("9 uploads complete, 1 failed"). Polite means it waits until the screen reader finishes what it is saying.
Coalesce bursts: if 5 small files finish within 2 seconds, say "5 files uploaded" once. Users who want the current percent can move the screen reader's reading cursor to the overall bar and hear its value text. The screen readers lesson explains how live regions queue and interrupt.
Keyboard access and reduced motion
- Every action is a real
buttonwith a name that includes the item: "Pause beach-042.jpg", "Retry beach-042.jpg". Ten buttons all called "Retry" are useless in a screen reader's list of buttons. - Pause and Resume swap the label on one button; keep focus on it so pressing Space twice pauses and resumes.
- When a row is removed (cancelled and dismissed), move focus to the next row's first button, or to the list heading if it was the last. Focus must never fall to the page body. The same applies when virtualization would unmount the focused row: keep the focused row rendered.
- Under
prefers-reduced-motion: reduce, drop the 200 ms glide and the striped indeterminate animation; show "Working" as text instead. The bar's value still updates: it is information, not decoration.
Check your understanding · Exercise 12
Which choices keep the tray usable with a screen reader? Select all that apply.
Running it in the interview
In a coding round you build the toy, then get extended: "now limit it to 3", "now add pause", "now make it real uploads". In a design round you talk through the real version. Either way, the same order works, and the order itself is part of the signal.
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 5 | Ask the scope questions: simulated or real, K fixed or live, how many items, sizes known | "Can K change while items run? That decides between a worker pool and a scheduler that reacts to state." |
| 5 to 12 | Write the item type and the state machine; state the invariant | "Running always equals the smaller of K and running plus queued. I'll test against that." |
| 12 to 22 | The reducer with fill; then the runner with AbortController | "The reducer only decides; the runner does the work. Two items finishing at once can't double-start anything." |
| 22 to 30 | Animation: timestamps, rAF, transform; the hidden tab case | "I compute progress from timestamps, so a background tab catches up on its first frame." |
| 30 to 38 | Real progress, overall by bytes, batching per frame, 300 rows | "XHR for upload progress; coalesce events per frame; status changes dispatch immediately." |
| 38 to 45 | Accessibility, the tray, ETA, leaving the page; then take questions | "One polite live region for milestones, never per-percent announcements." |
Draw the state machine first, before any architecture boxes. It is small, it is where most follow-up questions land, and it lets you answer "what if the user cancels while it is finishing?" by pointing at the diagram instead of reasoning in the air. In a coding round, write the reducer as a pure function and call it from a tiny store; the interviewer will see that adding pause or retry is one more transition, not a rewrite.
Think first
"Two items finish in the same millisecond. Walk me through what happens."
Each completion is its own promise callback, so the runner dispatches two succeed actions one after the other. Each dispatch runs the reducer to completion before the next starts: JavaScript runs one callback at a time.
The first marks item A done and fill starts the next queued item, so running is back at K. The second marks B done and fill starts another. No handler ever reads a stale counter, because the count is computed inside the reducer from the current state.
React may batch the two store updates into one render if they land in the same task; either way the screen ends up consistent.
Think first
"The user clicks Cancel at the same moment the upload succeeds. What does the row show?"
Whichever action reaches the reducer first wins, and the state machine makes the other harmless. If succeed lands first, the item is done and cancel has no transition from done, so it is ignored; the row shows done, which is the truth because the server has the file.
If cancel lands first, the item is cancelled and the runner aborts the request; a late succeed from that attempt is ignored because cancelled has no succeed transition. If the user has already retried, the attempt check drops it too.
The careful follow-up: the server may still have received the whole file before the abort. If that matters, the cancel should also send a delete request for that upload, which is the upload protocol's job (see the file uploader guide).
Think first
"A user drops 500 photos, then one urgent PDF. How does the PDF go first?"
Add it with a higher priority. Fill picks waiting items by priority, then by order, so the PDF takes the next free slot, within about one item's duration.
If even that is too slow, offer "Upload now": temporarily raise K by one for that item, or pause the youngest running photo to free a slot. Say which you would choose and why: pausing wastes less than you think with resumable uploads.
Mention starvation: if urgent items keep arriving, add aging so the photos still progress.
Check your understanding · Exercise 13
You have 10 minutes left and haven't covered rendering or accessibility. What is the best use of them? Select all that apply.
Summary
- The toy is a task queue with a concurrency limit. Keep one state object, change it only with a pure reducer, and derive every count from it; stored counters drift under races.
- Model each item as a state machine. Resume goes to queued, not running, and late events from a cancelled or older attempt are ignored, so the invariant holds without special cases.
- Fill free slots inside the reducer on every action: a sliding window by construction, where raising K starts work at once and lowering K lets running items finish.
- Real work plugs in as
run(item, signal, report). Pause and cancel abort it for real withAbortController; retries count failures and choose where the item rejoins the line. - Compute progress from timestamps or bytes, never from counted ticks. Paint it with one rAF loop writing
transform: scaleX(), and never let completion depend on frames. - Use
XMLHttpRequestfor upload progress in 2026; show an indeterminate bar when the total is unknown or the server is processing; weight overall progress by bytes. - Coalesce progress per animation frame, dispatch status changes at once, memoize rows, and virtualize long lists.
- One polite live region for milestones, labelled bars with useful value text, named per-item buttons, and a
beforeunloadguard only while work runs.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- MDN: the progress element Determinate and indeterminate states, and labelling.
- MDN: ARIA progressbar role aria-valuenow, aria-valuetext, and what leaving out the value means.
- MDN: Window.requestAnimationFrame() The timestamp argument and the pause in background tabs.
- MDN: XMLHttpRequest.upload Upload progress events.
- XMLHttpRequest Standard (WHATWG) Where the roughly 50 ms progress event rate is defined.
- MDN: AbortController Cancelling fetches and other work with a signal.
- Chrome for Developers: Heavy throttling of chained JS timers beginning in Chrome 88 Once a second, then once a minute, in hidden pages.
- Chrome for Developers: Streaming requests with the fetch API duplex: 'half', HTTP/2 only, and the restrictions.
- web.dev: Stick to compositor-only properties and manage layer count Why
transformandopacityanimate without layout or paint. - MDN: Window beforeunload event Sticky activation, the generic dialog, and the back/forward cache caveat.
- React: useSyncExternalStore Subscribing components to an external store with stable snapshots.
- React v18.0: automatic batching Which updates React batches, and why separate events are separate renders.
Concepts behind it
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- Layout, paint, and compositingWhat the browser redoes when something on the page changes after the first render, why moving a box with
transformis cheap while changing itswidthis not, and how to keep scrolling and animation inside a 16 ms frame. - Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.