Works with any backend npm install Zero dependencies

Uploading multiple files with JavaScript

Selecting many files is one attribute. Sending them well is the part that decides whether the upload survives a slow connection, reports honest progress, and does not fail all-or-nothing.

Selecting more than one file

The multiple attribute is the whole client-side requirement. Without it the input replaces its selection each time; with it, input.files is a FileList of everything the user picked.

<input type="file" id="picker" multiple>

document.getElementById('picker').addEventListener('change', (e) => {
    const files = Array.from(e.target.files);   // FileList -> Array
    console.log(files.length, 'file(s) selected');
});

A FileList is array-like but not an array — it has length and index access, and nothing else. Array.from() first, or map and filter will not be there when you reach for them.

Two things the attribute does not give you: dropped files arrive through DataTransfer.files on the drop event rather than through the input, and a folder selection needs webkitdirectory instead.

One request for all files, or one request each?

This is the decision that matters, and it is easy to make by accident. FormData lets you append every file under the same field name and send one request:

const form = new FormData();
for (const file of files) form.append('files', file);   // same name, many parts

await fetch('/upload', { method: 'POST', body: form });

It is the shortest code, and it is the wrong default for anything but a handful of small files:

  • It is all-or-nothing. One dropped connection at 95% loses every file, including the nine that had already transferred.
  • Progress is meaningless. The browser reports bytes sent for the whole combined body, so you cannot say which file is at what percentage — only that the batch is somewhere.
  • One file breaches the limit and all of them fail. Body-size limits apply to the combined request, so the total is what has to fit, not the largest file.
  • Nothing can be retried in isolation, because there is nothing smaller than the batch to retry.

One request per file inverts every one of those. Each file succeeds, fails, retries and reports progress on its own, and the server handler gets one file at a time instead of a variable-length list. The cost is that you now own a queue.

Do not send them all at once

The obvious way to upload an array of files is the one to avoid:

// Fires every request simultaneously - do not do this
await Promise.all(files.map(f => uploadOne(f)));

Fifty files means fifty concurrent requests. The browser caps its own connections per host and queues the rest, so the requests do not actually run in parallel — they just all start, share the same upstream bandwidth, and each finish slower than if they had been ordered. Progress bars crawl together instead of completing one by one, and a per-request timeout can expire on a request that has been waiting rather than transferring.

A small fixed limit — two to four in flight — finishes the batch sooner and makes progress legible. MultipleUpload defaults to concurrency: 1, strictly one at a time, which is the safest thing to be wrong about:

const uploader = new MultipleUpload('#uploader', {
    url:         '/upload',
    multiple:    true,        // NOTE: the default is false
    concurrency: 3,           // default 1; 'auto' adapts between 2 and maxConcurrency
    maxFiles:    20,          // 0 = no limit
    autoUpload:  true         // false to require an explicit upload click
});

Two of those defaults surprise people. multiple is false, so a multi-file uploader has to ask for it. And concurrency is 1, so uploads are sequential until you say otherwise. Setting concurrency: 'auto' starts at two and adapts upward to maxConcurrency (6 by default) based on how the transfers are actually going, which is the better choice when your users' connections vary and you cannot pick one number for all of them.

Progress across a queue

With one request per file there are two different progress numbers, and conflating them is the usual bug. Per-file progress comes from that file's own upload; overall progress is bytes across the whole queue — which is not the average of the percentages, because a 4 GB video and a 12 KB icon do not count equally.

uploader.on('progress', (overall, uploader) => {
    // overall queue progress, weighted by bytes
});

uploader.on('taskProgress', (task, uploader) => {
    // this one file - the percentage is on task.progress
    console.log(task.fileName, task.progress);
});

The event and the option callback carry the same information in slightly different shapes: on('taskProgress') receives (task, uploader) and you read task.progress, while the onTaskProgress option is handed (task, progress, uploader) directly.

Averaging per-file percentages produces a bar that races to 90% on the small files and then appears to freeze for several minutes on the large one. Weighting by bytes is what makes the number mean something.

What still has to happen on the server

None of the above is a security boundary. Extension and size checks in the browser exist to save the user a pointless upload, and anyone can skip them by posting to your endpoint directly. Re-validate every file on arrival, cap the size while writing rather than trusting a declared length, and never build a path from a client-supplied file name. See upload security.

One limit worth checking early: many servers cap the number of parts in a multipart request as well as the total size, and the error when you exceed it rarely says so. One request per file sidesteps that too.