Site icon Filestack Blog

Bulk Upload UI That Supports Queues, Retries, and Partial Success

Bulk Upload UI That Supports Queues, Retries, and Partial Success

You select 50 files to upload. A single progress bar starts moving. It reaches 82% and suddenly stops. You don’t know which files were uploaded, which ones failed, or what went wrong. There isn’t even a retry button.

So you select all 50 files again and upload them once more. Now some files may be uploaded twice, while others may still be missing.

This happens because bulk uploads are shown as one progress bar instead of showing the status of each file. The real problem isn’t the upload itself. It’s that you can’t see what’s happening.

A bulk upload UI lets users submit dozens or hundreds of files with a visible queue, per-file state, partial-success reporting, and retry that never discards completed work. The core pattern is a bounded concurrency queue that renders queued, uploading, done, and failed states per row. Filestack’s picker implements this pattern natively, with parallel uploads and per-file callbacks.

At Filestack, we spend a lot of time thinking about what happens after someone clicks “upload,” because that’s usually where things quietly break. In this post, we’ll build out the pattern step by step: what users expect, how to show state per file, how to report partial success honestly, and how to make uploads feel fast without lying about progress.

Key Takeaways

What Users Expect From Bulk Upload

Before writing any code, it helps to know what “good” actually looks like from the user’s side.

When someone uploads a batch of files, they’re really asking for four small promises. First, they want to see the queue, so they know what’s waiting and what’s already running. Second, they want to see each file’s own status, not just one shared bar. Third, if something goes wrong, they don’t want to lose the files that already finished. And fourth, if a few files fail, they want to retry just those files, not the whole batch again.

Miss any one of these promises, and the upload experience feels broken, even if the backend is working fine. This is also the difference between a plain uploader and a real importer. A uploader just moves bytes while an importer tells you, honestly, that 47 of 50 files made it, and lets you fix the other 3 without redoing the whole job.

Keeping these four promises in mind makes the rest of this post a lot easier to follow, because every pattern below exists to serve one of them.

The Queue as UI, States Users Can See

If the four promises above are the “why,” this section is the “how.” A queue is not just a backend concept; it needs to be something the user can actually see.

The simplest way to do this is to treat every file as its own row in a list, and give that row a state. At any point, a file can be in one of a few states: queued, uploading, done, failed, or retrying. This small vocabulary is the minimum you need to be honest with users about what’s happening. Anything less, like a single spinner for the whole batch, hides information the user actually needs.

In React, this maps naturally to a list keyed by file ID, where each row re-renders on its own as its state changes:

function UploadQueue({ files }) {

  return (

    <ul className="upload-queue">

      {files.map((file) => (

        <li key={file.id} className={`row row--${file.status}`}>

          <span className="name">{file.name}</span>

          <span className="status">{file.status}</span>

          {file.status === "uploading" && (

            <progress value={file.progress} max={100} />

          )}

        </li>

      ))}

    </ul>

  );

}

Because each row is keyed by file.id, updating one file’s status doesn’t touch the others. That’s what lets a single failed file sit quietly in its own row while 49 others keep uploading around it.

Filestack’s picker already wires this state machine under the hood for multi-file uploads, so if you’re building on top of it, you mostly need to render the states it hands you rather than track them yourself.

Once every file has its own visible state, the next problem is what to do when some of those states turn into “failed.”

Partial Success and Retry Without Losing Work

This is the section most bulk uploaders get wrong, because it’s tempting to treat the batch as one job with one outcome. In reality, a batch of 50 files is really 50 small, independent jobs.

If you’re using Promise.all to fire off your uploads, one rejected promise fails the entire batch, even if 49 files uploaded fine. Switching to Promise.allSettled fixes this at the root, since it waits for every upload to finish or fail without stopping early. From there, you can loop through the results and separate the wins from the losses.

Once you know which files failed, the retry button should only touch those rows. Here’s a simple handler for that:

async function retryFailed(files, uploadFn) {

const failed = files.filter((f) => f.status === "failed");

const results = await Promise.allSettled(

failed.map((f) => uploadFn(f).then((res) => ({ id: f.id, res })))

);

results.forEach((r, i) => {

const id = failed[i].id;

if (r.status === "fulfilled") {

markDone(id, r.value.res);

} else {

markFailed(id, r.reason);

}

});

}

Notice the completed files never enter this function at all. Their CDN URLs stay exactly as they were before the retry, so nothing gets re-uploaded or re-processed by accident.

One more small thing worth adding here: idempotency keys. If a request times out but actually succeeded on the server, a naive retry can create a duplicate file. Attaching a stable key per file (like a hash of its name, size, and last-modified time) lets your backend recognise “I’ve already seen this one” and skip the duplicate.

With retries scoped correctly, the batch stops being all-or-nothing. It becomes a set of small jobs that can each fail and recover on their own, which is really what “success rate” should measure in the first place, not whether the whole batch passed, but how many individual files made it through, and how easily the rest can be fixed.

Speed and Perceived Speed

Getting the states and retries right solves the trust problem. But bulk upload also has a performance problem, and it starts with a browser limit most developers forget about.

Browsers cap parallel HTTP/1.1 connections at around 6 per origin. If your UI tries to fire off 50 uploads at once, most of them will just sit blocked, waiting for a free connection, while your progress bar looks frozen. The fix is to queue past that limit on purpose, using a small pool of 3 to 6 concurrent uploads instead of an unbounded flood of requests.

This bounded approach also fixes a smaller but annoying issue: the lying progress bar. If your aggregate bar counts files instead of bytes, uploading one 200 MB video next to nine tiny thumbnails will make the bar jump to 90 percent and then crawl for the last 10. Weighting the bar by bytes uploaded, instead of files completed, keeps it honest and steady.

A few small touches go a long way for perceived speed too. Showing an optimistic thumbnail the moment a file is selected, before it even starts uploading, makes the queue feel alive right away.

We covered more of this ground, including code for uploading multiple files in parallel, in an earlier post if you want to go deeper on the concurrency side.

The Managed Route, The Queue You Do Not Maintain

Everything above is buildable by hand, and plenty of teams do build it. But it’s worth being honest about what you’re signing up for: a queue, a retry system, and a progress calculator that all need testing, monitoring, and the occasional 2 a.m. bug fix.

If you’d rather skip maintaining that queue yourself, the whole pattern also ships prebuilt. A production upload ui renders the queue, runs bounded parallel uploads, and reports per-file success and failure out of the box, so you get the same four promises without owning the state machine behind them.

This matters most for teams that don’t have upload UX as their core product. Picture a product manager at a real estate platform who needs agents to upload hundreds of listing photos and PDFs during a busy weekend. They don’t need to reason about Promise.allSettled or connection caps. They need something that works the first time, reports failures clearly, and doesn’t need a developer on call.

💡If your bulk uploader needs to handle large volumes of small files safely, it’s worth giving best practices for secure file uploads a read.

Conclusion: Report Truthfully, Retry Surgically

Bulk upload isn’t just about uploading files faster. It’s about showing users exactly what’s happening. They should be able to see which files are waiting, uploading, completed, or failed. If something goes wrong, only the failed files should need to be uploaded again.

Whether you build this logic yourself or use a file picker that already handles it, the goal is the same: make the upload process clear and reliable. When users always know the status of every file, even large uploads feel smooth instead of frustrating.

If you want to see the pattern in action, drop 100 files on the Filestack picker sandbox and watch the per-file states update in real time.

FAQ

How many files should you upload in parallel?

Somewhere between 3 and 6 at a time works well for most browsers. Queue the rest and let them fill in as slots open up.

Should one failed file stop the whole batch?

No. Isolate failures per file, and offer a retry that’s scoped only to the failed rows, not the entire upload.

How should batch progress be shown?

Use per-file states (queued, uploading, done, failed) alongside one aggregate bar that’s weighted by bytes, not by file count.

Exit mobile version