A good multi-file upload flow does more than accept files. It helps users add several items, review them, change the order, remove mistakes, understand what failed, and retry without starting over. This guide walks through a practical browser-based implementation for a multi file upload UI using JavaScript, with a focus on stable state management, predictable UX, and patterns you can keep updating as upload APIs and frontend frameworks evolve.
Overview
If you need to support more than one file, the default file input is only the starting point. Real upload workflows usually need a queue, per-file status, progress display, validation, reordering, removal before upload, and a clear retry path for failures. That is true whether you are building an asset manager, a form with attachments, a media uploader, or an internal tool.
The simplest mistake is treating the file input itself as the source of truth. A better approach is to treat selected files as records in your own client-side state. Once files enter your queue, users should be able to:
- add more files in batches
- see file names, sizes, and preview metadata where useful
- reorder uploaded files or queued files before submission
- remove file before upload without resetting the full form
- start uploads automatically or manually
- retry failed file upload attempts individually
- finish with a server response that preserves intended order
For most teams, the durable model looks like this:
- Selection layer: native file input or drag and drop
- Queue layer: normalized client state for every file
- Validation layer: type, size, count, and duplicate checks
- Upload layer: per-file request handling, progress, cancellation, retry
- Commit layer: send final order and uploaded asset references to your app
This separation matters because the browser's FileList is not designed to behave like a fully editable collection. If your interface requires ordering and removal, your own array of file items should drive the UI.
Step-by-step workflow
Here is a practical workflow you can implement in plain JavaScript or adapt to your framework of choice.
1. Define a queue model before writing UI code
Start by deciding what one file item looks like in state. Keep both browser file data and upload lifecycle fields.
const item = {
id: crypto.randomUUID(),
file, // original File object
name: file.name,
size: file.size,
type: file.type,
status: 'queued', // queued | validating | ready | uploading | success | error | removed
progress: 0,
error: null,
serverId: null,
previewUrl: null,
sortOrder: 0,
fingerprint: null
};The key idea is that each file gets a stable client ID. Do not rely on array index as identity, especially if users can reorder items or remove them from the middle of the list.
2. Ingest files from both file input and drag-and-drop
Your input should support repeated selection so users can add files in multiple passes. After every selection, copy the files into your queue state and reset the input value so the same file can be selected again if needed.
input.addEventListener('change', (e) => {
const files = Array.from(e.target.files || []);
addFilesToQueue(files);
e.target.value = '';
});If you also support drag-and-drop, route dropped files through the same addFilesToQueue function. That keeps behavior consistent across entry points.
3. Normalize and validate immediately
Validation should happen as close to selection as possible. Common checks include:
- maximum file count
- per-file size limit
- allowed MIME types or extensions
- duplicate detection
- image dimension checks when relevant
Mark invalid items clearly rather than silently discarding them. In many products, it is better to add them to the list with an error state so the user understands what happened.
For more on preflight checks, see How to Validate Uploaded Files in the Browser Before Sending.
4. Generate lightweight metadata for display
Users need enough detail to review the queue. At minimum, show:
- filename
- readable size
- type or extension
- status label
- upload progress
For images, a thumbnail can be useful, but create object URLs carefully and revoke them when items are removed to avoid memory leaks.
item.previewUrl = URL.createObjectURL(file);
// later when removing
URL.revokeObjectURL(item.previewUrl);5. Support removal before upload as a first-class action
Removing a file should be easy and local. Users should not need to reopen the picker and rebuild the whole list. In practice, this means each row gets its own remove button.
When a file is removed:
- update state by ID, not by displayed index
- recalculate visible order if order matters
- revoke any preview URL
- if upload is already in flight, cancel the request if your transport supports it
A clean remove action is one of the highest-value improvements you can make in a multiple file upload JavaScript workflow.
6. Make ordering explicit in the interface
If file order affects gallery display, document order, or processing priority, users should be able to change it. There are two common patterns:
- Move up/down controls: simple, keyboard-friendly, good for accessibility
- Drag-and-drop sorting: faster for larger lists, but requires more care on touch devices and screen readers
Whichever pattern you choose, reorder the queue array and update a stable sortOrder field. Do not wait until form submission to derive the order, because later retries and partial uploads may depend on it.
If your server creates uploaded assets before the final form submit, keep client order separate from upload completion order. Files may finish uploading in a different sequence than the user intended.
7. Decide between auto-upload and staged upload
There are two common models:
- Auto-upload: files start uploading as soon as they are added and validated
- Staged upload: users build the list first, then click Upload or Save
Auto-upload feels fast and works well for media-heavy interfaces. Staged upload is often easier when order, metadata editing, or grouped validation matters.
Either model can work. The important thing is to keep state transitions visible. A user should always know whether a file is queued, uploading, successful, or failed.
8. Upload each file as an independent task
Even if you submit one final form later, treat each file upload as its own unit of work. That makes progress tracking, cancellation, and retry much easier.
async function uploadItem(item) {
updateItem(item.id, { status: 'uploading', progress: 0, error: null });
try {
const result = await sendFile(item.file, (progress) => {
updateItem(item.id, { progress });
});
updateItem(item.id, {
status: 'success',
progress: 100,
serverId: result.id
});
} catch (err) {
updateItem(item.id, {
status: 'error',
error: 'Upload failed. Try again.'
});
}
}Limit concurrency for large batches so the browser and network do not get overloaded. A small pool is usually easier to reason about than firing every request at once.
9. Build retry around idempotency, not wishful thinking
The phrase retry failed file upload sounds simple, but duplicate creation is a common backend and UX problem. A retry should refer to the same logical file item, not create a new queue row or a new attachment record unless your system intends that behavior.
Helpful safeguards include:
- stable client item IDs
- file fingerprints based on name, size, and lastModified, or a stronger hash when needed
- server-side idempotency keys
- clear distinction between transport failure and completed-but-unconfirmed upload
For a deeper treatment, see How to Handle File Upload Retries Without Creating Duplicates.
10. Preserve final order when submitting references
Once uploads succeed, your app often needs to send an ordered list of uploaded asset IDs or storage keys to the server. That payload should come from your current queue order, not from the order responses arrived.
const payload = queue
.filter(item => item.status === 'success')
.sort((a, b) => a.sortOrder - b.sortOrder)
.map(item => ({ id: item.serverId, order: item.sortOrder }));This is the step that makes reordering meaningful beyond the frontend.
Tools and handoffs
A stable upload experience is usually the result of clean boundaries between frontend behavior and backend responsibilities.
Frontend responsibilities
- file selection and queue management
- local validation and error messaging
- displaying progress and status
- ordering, removal, and retry controls
- collecting final ordered references for submission
Backend responsibilities
- issuing upload destinations or accepting file streams
- validating file types and limits again on the server
- returning stable asset identifiers
- handling duplicate retry requests safely
- persisting final order and association with parent records
Architecture handoffs to decide early
Before implementation, align on a few decisions:
- Direct-to-cloud or proxy upload: if files go straight to object storage, the frontend usually needs a signed target and a final callback or commit step. See Direct-to-Cloud Upload Architecture: Pros, Cons, and Decision Checklist and Signed Upload URLs vs Proxy Uploads: Security and Cost Comparison.
- Temporary storage lifecycle: if uploads happen before final form save, define cleanup rules for abandoned files. See Temporary File Storage for Upload Workflows: Cleanup, Retention, and Cost Control.
- Abuse controls: your UI can limit file count and size, but backend rules still matter. See How to Prevent File Upload Abuse: Rate Limits, Quotas, and Type Restrictions.
UI patterns worth standardizing
To keep your multi file upload UI maintainable, define a small set of reusable patterns:
- a queue row component with status, actions, and progress
- a shared status vocabulary: queued, uploading, success, error, removed
- a single formatter for bytes and file labels
- an upload controller that owns concurrency and cancellation
- an ordering helper that works for keyboard and pointer input
This is where frontend workflow discipline matters. If upload logic is scattered across form code, modal code, and one-off event handlers, bugs tend to show up around edge cases like retries, same-file reselection, and partial success states.
Quality checks
Before you ship, test the flow as a user would, not just as a developer with ideal files and a fast connection.
UX checks
- Can users add files in multiple rounds?
- Can they remove an item without losing the rest?
- Is the order clearly visible and easy to change?
- Does the interface explain why a file is invalid?
- Can users retry only the failed items?
- Is the success state different from merely queued?
State management checks
- Removing one item does not disturb the identity of others
- Reordering works before and after some files have uploaded
- Retry does not create duplicate rows
- Final submitted order matches visible UI order
- Cancelled uploads do not remain stuck in uploading state
Cross-browser and device checks
File inputs behave differently across browsers and platforms, especially around repeated selections, drag-and-drop, mobile capture flows, and folder-like behavior. Review Cross-Browser File Input Quirks Developers Should Test before calling the feature done.
Progress and trust checks
Users tend to lose confidence when progress bars jump, freeze, or disappear after an error. Make sure progress is scoped per file and, if you show aggregate progress, that it does not hide failures within the batch. This is covered in Upload Progress Bars That Users Trust: UX Patterns and Edge Cases.
File-specific checks
If your uploader is image-heavy, add checks for metadata, compression expectations, and dimensions. If you support folders, test nested structures and naming collisions. Useful follow-up reading includes Best Practices for Uploading Images on the Web and How to Support Folder Uploads in the Browser.
When to revisit
Treat your upload flow as a living workflow rather than a one-time component. Revisit it whenever the environment changes.
Good triggers for an update include:
- you add a new file type or larger size limits
- you move from proxy uploads to signed uploads
- you introduce mobile-heavy usage and need stronger touch support
- you add drag sorting or keyboard accessibility improvements
- you start seeing duplicate uploads, abandoned files, or unclear error reports
- browser behavior changes around file inputs, progress events, or storage APIs
A practical review checklist:
- Map the current states in your queue model and remove any ambiguous status names.
- Confirm that reorder, remove, cancel, and retry all operate on stable IDs.
- Audit whether final server order matches the visible list in every partial-success scenario.
- Review cleanup rules for incomplete uploads and temporary storage.
- Retest on current desktop and mobile browsers.
- Ask whether users can recover from one bad file without rebuilding the whole batch.
If you only remember one principle from this guide, make it this: the file input chooses files, but your queue owns the workflow. Once you model files as durable items with identity, status, order, and retry behavior, the rest of the interface becomes much easier to reason about. That foundation supports a better multiple file upload JavaScript implementation today and gives you room to evolve the flow as browser capabilities and backend architecture change.