Upload Queue Concurrency Control

When a user drops two hundred photos or a folder of videos onto your page, starting every upload at once is the worst thing you can do. Browsers cap connections per host (six for HTTP/1.1; HTTP/2 multiplexes but the uplink does not get wider), each in-flight request holds memory for its body, progress events flood the main thread, and one slow file blocks nothing while forty fast ones fight for the same bandwidth. The result is slower total throughput, a frozen interface and a pile of timeouts that look like network failures.

An upload queue fixes this by deciding what runs now. It holds every pending file, runs a bounded number at a time, starts the next as soon as one finishes, and gives users control — reorder, pause, cancel, retry. Inside large files, a second limit bounds how many chunks of that file are in flight. Around both, adaptive logic tunes concurrency and chunk size to the throughput the connection actually delivers. This topic belongs to frontend UX, chunking and progress tracking and sits between the transfer mechanics in resumable upload state machines and the display logic in realtime upload progress events.

The three guides under this topic go deeper: limiting concurrent uploads with a promise pool builds the core scheduler, prioritizing and pausing items in an upload queue adds user control, and adapting chunk size to measured throughput tunes transfers to the connection.

Prerequisites

  • An upload function per file that returns a promise and accepts an AbortSignalfetch, XHR wrapped in a promise, or a chunked uploader such as the one in resuming uploads after network loss.
  • A way to get upload URLs per file (presigned URLs or a resumable session endpoint).
  • A UI framework or plain DOM code that can render a list from state; the queue itself is framework-free.
  • Evergreen browsers; everything here uses standard APIs (AbortController, Promise, performance.now()).

How it works

Upload queue with bounded file and chunk concurrency Files enter a pending list ordered by priority. The scheduler runs at most three files at once. Each running file uploads at most four chunks at once. When a file finishes or fails, the scheduler starts the next pending file. Paused and failed items leave the running set without blocking others. Two limits: files at once, chunks per file pending cover.jpg · high IMG_0412.heic IMG_0413.heic … 196 more running (max 3) trip.mov 4 chunks in flight IMG_0410.heic single request IMG_0411.heic single request done starts next pending paused / failed frees a slot Worst case in flight = 3 files × 4 chunks = 12 requests — bounded no matter how many files are dropped.
The queue bounds total requests in flight regardless of how many files the user adds.

The queue is a small state machine per item — pending, running, paused, done, failed, cancelled — plus a scheduler that runs whenever state changes. The scheduler’s rule is simple: while the number of running items is below the limit and there is a pending item, pick the highest-priority pending item and start it. Every transition out of running (success, failure, pause, cancel) calls the scheduler again. There is no polling and no timer; the queue advances exactly when capacity frees up.

Two limits matter because files differ in size by orders of magnitude. A queue of small photos benefits from several files in parallel, each as one request, because per-request latency dominates. A single large video benefits from several chunks in parallel, because one TCP stream rarely fills a fast uplink. Combining a file limit with a per-file chunk limit handles both, and the product of the two bounds total requests in flight.

Why not simply “all at once”? Because the uplink is shared. On a 20 Mbps connection, forty simultaneous uploads each get half a megabit; none finishes quickly, all are exposed to timeouts for longer, and a user who wanted to see the first photo appear waits as long as for the last. Bounded concurrency finishes files sooner, one after another, which is also what users perceive as “fast”.

Step-by-step implementation

1. Model the queue state

Keep the queue as plain data so any UI can render it and any code can inspect it. Each item has an ID, the File, a priority, its status, byte progress and an AbortController while running.

export type Status = "pending" | "running" | "paused" | "done" | "failed" | "cancelled";

export interface QueueItem {
  id: string;
  file: File;
  priority: number;            // higher runs first
  status: Status;
  sent: number;                // bytes acknowledged
  attempts: number;
  error?: string;
  controller?: AbortController;
  addedAt: number;
}

export type UploadFn = (item: QueueItem, signal: AbortSignal, onProgress: (sent: number) => void) => Promise<void>;

2. Write the scheduler

The scheduler is the only place that starts work. It picks by priority, then by insertion order, so equal-priority files upload in the order the user added them.

export class UploadQueue extends EventTarget {
  private items = new Map<string, QueueItem>();
  constructor(private upload: UploadFn, public concurrency = 3) { super(); }

  add(files: File[], priority = 0) {
    for (const file of files) {
      const id = crypto.randomUUID();
      this.items.set(id, { id, file, priority, status: "pending", sent: 0, attempts: 0, addedAt: performance.now() });
    }
    this.changed();
  }

  private running() { return [...this.items.values()].filter((i) => i.status === "running").length; }

  private nextPending(): QueueItem | undefined {
    let best: QueueItem | undefined;
    for (const i of this.items.values()) {
      if (i.status !== "pending") continue;
      if (!best || i.priority > best.priority || (i.priority === best.priority && i.addedAt < best.addedAt)) best = i;
    }
    return best;
  }

  private schedule() {
    while (this.running() < this.concurrency) {
      const item = this.nextPending();
      if (!item) break;
      this.start(item);
    }
  }

  private async start(item: QueueItem) {
    item.status = "running";
    item.attempts++;
    item.controller = new AbortController();
    this.emit(item);
    try {
      await this.upload(item, item.controller.signal, (sent) => { item.sent = sent; this.emit(item); });
      item.status = "done";
    } catch (e: any) {
      if (item.status === "running") {                 // not paused or cancelled by the user
        item.status = "failed";
        item.error = e?.message ?? String(e);
      }
    } finally {
      item.controller = undefined;
      this.changed(item);
    }
  }

  private emit(item: QueueItem) { this.dispatchEvent(new CustomEvent("item", { detail: item })); }
  private changed(item?: QueueItem) { if (item) this.emit(item); this.schedule(); this.dispatchEvent(new Event("change")); }
}

3. Add user control

Pause aborts the in-flight request but keeps the item and its progress; resume puts it back in pending. Cancel aborts and discards. Retry resets a failed item to pending. Because each action changes status before aborting, the catch in start can tell a user action from a real failure.

// inside UploadQueue
pause(id: string) {
  const i = this.items.get(id);
  if (!i || (i.status !== "running" && i.status !== "pending")) return;
  const wasRunning = i.status === "running";
  i.status = "paused";
  if (wasRunning) i.controller?.abort(new DOMException("paused", "AbortError"));
  this.changed(i);
}
resume(id: string) { const i = this.items.get(id); if (i?.status === "paused") { i.status = "pending"; this.changed(i); } }
cancel(id: string) {
  const i = this.items.get(id);
  if (!i || i.status === "done") return;
  i.status = "cancelled";
  i.controller?.abort(new DOMException("cancelled", "AbortError"));
  this.changed(i);
}
retry(id: string) { const i = this.items.get(id); if (i?.status === "failed") { i.status = "pending"; i.error = undefined; this.changed(i); } }
setPriority(id: string, p: number) { const i = this.items.get(id); if (i) { i.priority = p; this.changed(i); } }

4. Bound chunks inside each file

For files above a threshold (say 16 MB), the UploadFn uploads chunks with its own small pool — the same promise-pool pattern at a finer grain. Pass the item’s signal down so pausing the file aborts all its chunks. The resumable upload protocol decides what “resume from sent” means: for S3 multipart, it is the list of completed parts; for tus, the server’s Upload-Offset.

export async function runPool<T>(tasks: (() => Promise<T>)[], limit: number, signal: AbortSignal): Promise<T[]> {
  const results: T[] = new Array(tasks.length);
  let next = 0;
  async function worker() {
    while (next < tasks.length) {
      signal.throwIfAborted();
      const idx = next++;
      results[idx] = await tasks[idx]();
    }
  }
  await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
  return results;
}

5. Render from events, throttled

Progress events can fire hundreds of times per second across twelve requests. Update a data structure on each event and render at most once per animation frame; rendering smooth progress bars without jank covers the rendering side, and aggregating progress across multiple files the overall total.

Configuration reference

Recommended starting values for queue settings File concurrency of 3 for mixed uploads, up to 6 for many small files. Chunk concurrency of 4 per large file. Chunk size of 8 MB, adapted between 5 and 64 MB. Large-file threshold of 16 MB. Retry up to 5 attempts with jittered backoff. Starting values that work for most products setting default adjust when files at once 3 many small files: up to 6 chunks per file 4 fast uplink, high latency: 6 chunk size 8 MB adapt between 5 and 64 MB chunking threshold 16 MB below it, one request per file retries per item 5, jittered then mark failed, keep others going Total in flight = files × chunks; keep it at or below about 12 to avoid starving the page's other requests.
Measure on your users' real connections before tuning beyond these values.

The values above assume a mix of photos and videos over typical broadband or 4G. The single most important number is the total in flight. Above roughly a dozen concurrent requests to the same origin, gains flatten and the page’s own API calls start to queue behind uploads on HTTP/1.1 connections. If your uploads go to a different host (a storage bucket) than your API, the API is not affected by the browser’s per-host limit, but bandwidth is still shared.

Chunk size trades per-request overhead against retry cost. Very small chunks waste time on request setup; very large chunks lose more work when one fails and make progress bars jumpy. S3 multipart requires parts of at least 5 MB (except the last) and at most 10,000 parts, which sets the lower and upper bounds for large files.

Edge cases and gotchas

Pause during the last bytes. A request aborted just as the server finishes processing may have succeeded. Resumable protocols handle this: on resume, ask the server for the current offset or completed parts rather than trusting local state.

Many items in failed stall the batch visually. Keep failed items visible with a retry button and a “retry all” action, but never let them occupy running slots. The scheduler above only counts running.

Tab backgrounded on mobile. Timers slow and connections may be suspended when the tab is hidden. Uploads continue in most desktop browsers; on iOS they often pause. Background-capable approaches are covered in background and offline uploads.

Adding files while a batch runs. New files join pending and are picked up as slots free. Give newly added files the same default priority so earlier files are not starved, unless the user explicitly asks for “upload this next”.

Memory with thousands of files. File objects are references, not loaded bytes, so holding thousands is cheap. What is expensive is creating previews or reading files eagerly; generate thumbnails lazily for visible rows only.

Duplicate drops. Users drop the same folder twice. Deduplicate on add by name, size and lastModified, and let the user confirm if they really want a second copy.

Handling failures without stopping the batch

A queue should treat a failure as a property of one item, not of the batch. Retry transient errors (network failures, 5xx, 429) with jittered backoff inside the item’s upload function, as in implementing exponential backoff for failed chunks; mark the item failed only after retries are exhausted or on a permanent error (403, 413, a validation rejection). The rest of the batch keeps going. When the batch ends, summarise honestly — “196 uploaded, 4 failed” with the reasons grouped — which reporting partial batch failures covers in detail.

Network-wide failures are different. When the browser goes offline, every running item fails at once; retrying each independently wastes attempts. Detect the offline event and pause the whole queue, then resume on online, as in reacting to offline and online events during uploads. A queue-level pause keeps item states intact and avoids burning retry budgets.

Item-level versus queue-level failure handling A single item failing with a transient error retries with backoff while other items continue. A permanent error marks only that item failed. Going offline pauses the whole queue without consuming retries, and coming back online resumes all paused items. Scope the reaction to the scope of the failure transient, one item retry with backoff others keep running permanent, one item mark failed with reason slot goes to next file offline, everything pause the queue resume on online no retries spent A queue-level pause preserves every item's state, so recovery is a single resume.
Retries are for one item's bad luck; pausing is for everyone's missing network.

Choosing concurrency from real data

The defaults are a start, not an answer. Instrument uploads with the bytes, duration and concurrency at the time, and look at throughput per batch across your users’ connection types. You will usually find that total throughput climbs steeply from one to three concurrent requests and flattens by six; that the flattening point is lower on mobile networks; and that very high concurrency increases failure rates on weak connections. Adapting chunk size to measured throughput turns those measurements into a live control loop so each client settles near its own best value.

Also consider the server side. Each concurrent chunk is a request your storage or API must accept; presigned URL issuance has rate limits (rate limiting presigned URL issuance), and a client that fires twelve requests per second for URLs can trip them. Batch URL requests — ask for the next several part URLs at once — to keep API calls low while chunks stay parallel.

Surviving reloads and navigation

A queue that lives only in memory disappears when the user reloads, crashes the tab or closes the laptop. For short batches that is acceptable; for a folder of videos it is not. Persist the parts of the queue that cannot be recomputed: each item’s ID, file name, size, lastModified, priority, status and — for resumable transfers — the upload session identifier and acknowledged offset or completed parts. IndexedDB is the right store, as described in persisting upload state in IndexedDB. Write on status changes and at most every few seconds for progress, not on every progress event.

The File objects themselves are the hard part. In most browsers a File from an input or a drop can be stored in IndexedDB and read back after a reload, but only while the underlying file still exists and has not changed. Check size and lastModified when restoring; if either differs, mark the item as needing the user to pick the file again. Where the File System Access API is available, a stored file handle can be re-opened with the user’s permission, which survives longer. On restore, put every previously running item back to pending — the network connections are gone — and let the scheduler start them again; the resumable protocol takes care of skipping what already arrived.

Tell users what happened. A banner such as “12 uploads were interrupted — resume?” is better than silently restarting traffic on page load, particularly on metered connections.

Designing the queue interface

Users need three things from a queue interface: to see what is happening, to change the order, and to stop things. Show each item’s state with text as well as colour (“Waiting”, “Uploading 45%”, “Paused”, “Failed — file too large”), put pause, resume, cancel and retry controls on each row, and offer batch actions at the top. Let users move an item to the front (a “upload next” action sets it to a higher priority) rather than implementing drag-to-reorder, which is harder to make accessible and rarely needed. The patterns in accessible upload interfaces apply directly: announce state changes politely, keep keyboard focus stable when rows change, and never rely on colour alone.

Keep the overall status visible at the top — files done out of total, bytes sent, estimated time — and warn before the user closes the tab while work is pending (beforeunload). If uploads are resumable, say so: “Closing this tab will pause your uploads; they’ll continue when you return”.

Verification

  • Drop 200 small files and confirm the network panel never shows more than the configured number of upload requests at once.
  • Drop one 2 GB file and confirm chunk requests stay at the per-file limit and the total never exceeds files × chunks.
  • Pause a running file mid-transfer: its requests are cancelled, the next pending file starts immediately, and resuming continues from the last acknowledged byte.
  • Throttle to “Slow 4G” in DevTools and confirm the first files complete in a steady sequence rather than all finishing at the end.
  • Toggle offline in DevTools: running items pause without incrementing attempt counts, and going online resumes them.

Frequently Asked Questions

Is HTTP/2 multiplexing a reason to raise concurrency?

HTTP/2 removes the six-connection limit, but not the uplink’s capacity. Multiplexed streams still share bandwidth, so the same throughput curve applies; concurrency above a handful mostly adds overhead and risk.

Should small files be batched into one request?

For hundreds of tiny files (icons, documents under 100 KB), a single multipart request with several files, as in sending multiple files and fields in one request, can beat individual uploads. For photos and larger, per-file requests keep progress, retry and cancel simple.

Should uploads run in a Web Worker?

The network work already happens off the main thread; what costs main-thread time is progress handling and rendering. A worker helps when you also hash or compress files before upload, because those are CPU-bound. Keep the queue’s state where the UI can read it, and move only the heavy per-file processing into workers.

Where should the queue live — component state or a store?

Outside components. A plain class or store survives navigation within a single-page app, so uploads continue while the user moves to another screen, and any component can subscribe to it.