Prioritizing and Pausing Items in an Upload Queue
Model each queue item with an explicit status (pending, running, paused, done, failed, cancelled) and a numeric priority, and let a single schedule() function start the highest-priority pending items whenever a slot is free. Pausing sets the status to paused before aborting the item’s AbortController, so the upload’s rejection is recognised as a user action rather than a failure; resuming sets it back to pending and lets the scheduler restart it from the server-confirmed offset. “Upload next” raises an item’s priority above all others; “pause all” stops scheduling and pauses running items; cancel aborts and cleans up the server-side session.
Users drop a batch and then change their mind: the cover image must go first, the 4 GB video can wait until they are on Wi-Fi, one file was a mistake. A queue that only runs items in insertion order forces them to cancel and start over. This page belongs to upload queue concurrency control in frontend UX, chunking and progress tracking. It extends the scheduler from limiting concurrent uploads with a promise pool with state that can move in both directions.
When to use this approach
- Batches are large or slow enough that users want to reorder or hold back items.
- Uploads are resumable, so pausing does not throw away progress.
- You need a “pause all” for metered connections or low battery.
Prerequisites
- An upload function that accepts an
AbortSignaland can resume from a server-confirmed position — tus, GCS resumable sessions, or S3 multipart with a part list (building a resumable upload flow with tus). - A UI list that renders from queue state.
- Evergreen browsers (
AbortController,EventTarget,crypto.randomUUID).
Item states and transitions
Implementation
type Status = "pending" | "running" | "paused" | "done" | "failed" | "cancelled";
interface Item {
id: string; file: File; priority: number; seq: number; status: Status;
sent: number; controller?: AbortController; session?: string; error?: string;
}
type Uploader = (item: Item, signal: AbortSignal, progress: (sent: number) => void) => Promise<void>;
export class ControlledQueue extends EventTarget {
private items: Item[] = [];
private seq = 0;
private held = false; // "pause all"
constructor(private uploader: Uploader, private abortSession: (item: Item) => Promise<void>, public limit = 3) { super(); }
add(files: File[]) {
for (const file of files) this.items.push({ id: crypto.randomUUID(), file, priority: 0, seq: this.seq++, status: "pending", sent: 0 });
this.update();
}
/** Move an item ahead of everything else that is waiting. */
uploadNext(id: string) {
const top = Math.max(0, ...this.items.map((i) => i.priority));
const it = this.get(id);
if (it && (it.status === "pending" || it.status === "paused")) { it.priority = top + 1; it.status = "pending"; this.update(); }
}
pause(id: string) {
const it = this.get(id);
if (!it || !(it.status === "running" || it.status === "pending")) return;
const wasRunning = it.status === "running";
it.status = "paused"; // set first: the abort below must not look like a failure
if (wasRunning) it.controller?.abort(new DOMException("paused", "AbortError"));
this.update();
}
resume(id: string) { const it = this.get(id); if (it?.status === "paused") { it.status = "pending"; this.update(); } }
retry(id: string) { const it = this.get(id); if (it?.status === "failed") { it.status = "pending"; it.error = undefined; this.update(); } }
async cancel(id: string) {
const it = this.get(id);
if (!it || it.status === "done" || it.status === "cancelled") return;
it.status = "cancelled";
it.controller?.abort(new DOMException("cancelled", "AbortError"));
this.update();
if (it.session) await this.abortSession(it).catch(() => {}); // free server-side parts or sessions
}
pauseAll() { this.held = true; for (const it of this.items) if (it.status === "running") this.pause(it.id); this.update(); }
resumeAll() { this.held = false; for (const it of this.items) if (it.status === "paused") it.status = "pending"; this.update(); }
private get(id: string) { return this.items.find((i) => i.id === id); }
private pick(): Item | undefined {
return this.items
.filter((i) => i.status === "pending")
.sort((a, b) => b.priority - a.priority || a.seq - b.seq)[0];
}
private update() {
if (!this.held) {
while (this.items.filter((i) => i.status === "running").length < this.limit) {
const next = this.pick();
if (!next) break;
this.run(next);
}
}
this.dispatchEvent(new Event("change"));
}
private async run(it: Item) {
it.status = "running";
it.controller = new AbortController();
try {
await this.uploader(it, it.controller.signal, (sent) => { it.sent = sent; this.dispatchEvent(new Event("progress")); });
if (it.status === "running") it.status = "done";
} catch (e: any) {
if (it.status === "running") { it.status = "failed"; it.error = e?.message ?? String(e); }
} finally {
it.controller = undefined;
this.update();
}
}
}
Line-by-line on the decisions that matter
- Status before abort. When
pauseaborts the request, the uploader’s promise rejects. Because the status is alreadypaused, thecatchinrunleaves it alone instead of marking the item failed. The same trick distinguishes cancel from failure. - Priority plus sequence. Sorting by priority, then by insertion sequence, keeps the user’s order for everything they did not explicitly move. “Upload next” sets a priority one above the current maximum, so repeated uses stack in the order the user clicked.
- Paused items can be promoted. Choosing “upload next” on a paused item implies the user wants it now; the method moves it back to
pendingwith top priority. heldflag for pause all. Pausing every running item is not enough — the scheduler would immediately start the next pending ones. The flag stops scheduling until the user resumes.- Server-side cleanup on cancel. A cancelled multipart upload leaves parts billed in storage until a lifecycle rule removes them; a cancelled tus upload leaves a partial file. Calling an abort endpoint (
AbortMultipartUpload, tusDELETE) cleans up promptly. - Resumption from the server’s view.
sentis display state. On resume, the uploader should ask the server what it has (tusHEAD, S3ListParts) and continue from there; bytes in flight at pause time may or may not have arrived.
What pausing costs
For small files uploaded as one request, pausing simply means cancelling and starting again later; that is acceptable when the file takes seconds. For large files, pause only makes sense with chunked or resumable transfers. Choose the chunking threshold so that anything that would take more than about ten seconds to upload is chunked, and consider showing “Stop” instead of “Pause” for items that will restart from zero.
Pause all, networks and batteries
“Pause all” is the control users reach for when they notice a metered connection, a video call starting or a low battery. Pair it with automatic triggers you can detect: the offline event (pause, then resume on online), the Network Information API’s saveData flag and effectiveType where supported, and the Battery Status API where available. Automatic pauses should use a separate flag from the user’s own pause-all so going back online does not override a user’s explicit decision. Reacting to offline and online events during uploads has the event handling.
held flag with a set of reasons once automatic pausing exists.Making the controls usable
Each row needs visible, labelled buttons for the actions valid in its state: Pause and Cancel while running; Resume, “Upload next” and Cancel while paused; Retry and Remove when failed. Hide actions that do not apply rather than disabling them, so keyboard users do not tab through dead controls. When an action changes a row, keep focus on a sensible element — the Resume button replaces Pause in the same position — so keyboard focus does not jump to the top of the list. Announce state changes politely (“trip.mov paused”) through a live region; announcing upload progress to screen readers covers how to do that without flooding the user.
Configuration gotchas
Paused items restart from zero. The uploader started a new session instead of reusing the stored one. Keep the session ID (tus URL, GCS session URI, S3 upload ID) on the item and reuse it on resume.
Pause marks items as failed. The status was changed after abort(); the rejection handler ran first. Always change status before aborting.
Pause all leaves one upload running. Items that were between pick() and run() when the flag flipped. Because run sets running synchronously in the same tick, this cannot happen with the code above; it does happen if scheduling is deferred with setTimeout.
Cancelled multipart uploads keep costing money. The abort call failed silently. Add an AbortIncompleteMultipartUpload lifecycle rule as the safety net (expiring incomplete multipart uploads automatically).
Verification
- Add 20 files, choose “Upload next” on the last one: it starts as soon as a slot frees, before the others.
- Pause a running 1 GB upload at 40 %, resume it: the network panel shows it continuing near 40 %, not from zero.
- Pause all: running requests are cancelled and nothing new starts until Resume all.
- Cancel a multipart upload and confirm with
aws s3api list-multipart-uploadsthat it is gone.
Frequently Asked Questions
Should paused items keep their slot?
No. A paused item should free its slot so the next file can run; otherwise pausing one big file stalls the whole batch.
Is drag-and-drop reordering worth building?
Rarely. “Upload next” covers the real need, is easier to make accessible and works on touch devices without extra handling.
What happens to paused uploads when the tab closes?
In-memory state is lost. With resumable uploads and persisted session IDs, the queue can offer to resume them on the next visit, as described in upload queue concurrency control.