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

  1. An upload function that accepts an AbortSignal and 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).
  2. A UI list that renders from queue state.
  3. Evergreen browsers (AbortController, EventTarget, crypto.randomUUID).

Item states and transitions

Upload queue item state machine with user actions An item starts pending. The scheduler moves it to running. From running it can finish as done, fail as failed, be paused or be cancelled. Paused items resume to pending. Failed items can be retried back to pending. Pending items can also be paused or cancelled directly. Only the scheduler moves pending → running pending running done failed cancelled paused schedule pause resume retry
User actions change status; the scheduler reacts to the change.

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 pause aborts the request, the uploader’s promise rejects. Because the status is already paused, the catch in run leaves 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 pending with top priority.
  • held flag 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, tus DELETE) cleans up promptly.
  • Resumption from the server’s view. sent is display state. On resume, the uploader should ask the server what it has (tus HEAD, S3 ListParts) and continue from there; bytes in flight at pause time may or may not have arrived.

What pausing costs

Bytes lost when pausing with different upload styles A single-request upload loses everything on pause and restarts from zero. A chunked upload loses only the chunks in flight, up to chunk size times chunk concurrency. A resumable upload with small chunks loses at most a few megabytes. Pause is only cheap if resume is real single request pause = cancel restart from 0 fine for small photos chunked lose chunks in flight ≤ chunk × concurrency e.g. 4 × 8 MB = 32 MB resumable stream server keeps offset lose last few MB tus, GCS sessions Label the control honestly: for single-request uploads, "Stop" is more accurate than "Pause".
How much work a pause throws away depends on the transfer, not the queue.

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.

Separate hold reasons for the whole queue The queue runs only when no hold is active. A user hold is set by pause all and cleared only by resume all. A network hold is set by the offline event and cleared by the online event. Coming back online does not clear a user hold. Automatic and manual holds must not undo each other user hold pause all / resume all network hold any hold? OR of all reasons scheduler runs when none
Replace the single 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-uploads that 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.