File Upload UX
Progress, cancellation, retry, validation and preview — per file, not per form — plus the pre-signed URL that keeps a two-gigabyte video out of your application server.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What does a browser actually have to do to move a file from someone's disk into your storage, and what must the interface show while it happens?
Someone wants to attach five photos to a report and get on with their day. One of the five is the wrong photo, and one of them is going to fail.
Put <input type="file" multiple> in the form, post everything as one multipart request along with the text fields, and show a spinner until the server answers.
There is no progress. A spinner in front of a large file is indistinguishable from a hang, and fetch alone does not report upload progress — that is what XMLHttpRequest's upload events, or a streamed request body where supported, are still used for.
- There is no progress. A spinner in front of a large file is indistinguishable from a hang, and
fetchalone does not report upload progress — that is whatXMLHttpRequest's upload events, or a streamed request body where supported, are still used for. - There is no cancellation. The user picked the wrong file, realises immediately, and has no way out except reloading the page and losing the form (Cancelling a Request Nobody Is Waiting For).
- One failure fails everything. The fourth of five files is rejected, the whole multipart request fails, and the twenty minutes of typing in the same form goes with it (Submission: Method, Encoding and Doing It Once).
- Every byte goes through your application server: its memory, its request timeout, its body-size limit and its bill. A service that is otherwise cheap and stateless is now a file transfer proxy (Large Requests and Documented Limits).
- The only validation is client-side, and everything the client says about a file — its name, its type, its size — is a claim made by code the user controls (The Browser Security Model).
- A
Fileis a handle to something on disk, not a copy of it. If the file is moved, renamed or edited between selection and read, the read throws — commonly minutes later, in the middle of the upload. - On a phone, "a photo" is a large image in a format that may need converting, the browser may suspend the tab when the user switches apps, and the uplink is far slower than the downlink they are used to.
- A retry starts from zero, which for a large file on a poor connection means it will fail again in the same place.
What is actually happening
In the browser, not in the framework.
- A file input produces
Fileobjects — aBlobplus a name, a size, a last-modified time and atypethat the browser guessed, usually from the extension. None of it is verified (Input Types, Inputmode and Autocomplete). URL.createObjectURL(file)gives you a previewable URL without reading the bytes into JavaScript, which is why it is the right way to show a thumbnail. It also pins the file in memory until you revoke it (Memory Leaks).- There are two topologies, and choosing between them is the main architectural decision in this lesson. Through your server: browser → app server → storage. Direct to storage: browser asks your server for a pre-signed URL, uploads straight to the object store, then tells your server it is done (Object Storage).
- A pre-signed URL is a capability, not a link. Your server signs a narrow permission — one object key, one method, a content type, a maximum size, a short expiry — and hands it to the browser. The browser can do exactly that and nothing else (Presigned URLs).
- Progress requires an API that reports it. The bytes leave in either case; what differs is whether the transport tells you. Upload progress events, or a chunked upload where each completed part is a progress signal, are the two available shapes.
- Resumability comes from chunking. Split the file into parts, upload each part independently, remember which parts succeeded, and on resume send only the missing ones. Object stores expose this directly as a multipart upload.
- Cancellation is one-sided from the browser.
AbortControllerorxhr.abort()stops your side; whether a partial object exists on the other side, and what happens to it, is the storage layer's policy — usually a lifecycle rule that expires incomplete uploads (Storage Lifecycle: Hot, Warm, Archive, Delete).
What this makes the browser do
And which of it is avoidable.
- Reading from disk in chunks, which is I/O the page does not control and which can fail partway through.
- Decoding images for previews. A large photo decoded at full resolution to render a small thumbnail is a real memory and main-thread cost, repeated per file (Images and Fonts).
- Holding every object URL alive until revoked, which means an unrevoked preview keeps the whole file resident.
- Optional client-side work — downscaling in a canvas, computing a checksum, converting a format — all of which belongs in a worker rather than in front of the user's next interaction (Web Workers and the DOM Boundary).
- Occupying connections. Five parallel uploads on a slow uplink compete with the application's own requests, so the rest of the UI gets slower exactly while the user is watching a progress bar (Reading a Network Waterfall).
- Transferring the file to a worker, which for a
Blobis cheap because it is not copied byte by byte the way a typed array would be (Structured Clone and Transferables).
Through your server, or straight past it
The topology decision comes first because everything else depends on it. Routing bytes through your application server is simpler — one origin, one authentication path, one place to validate — and it makes your application server responsible for the memory, the connection duration, the body-size limits and the bandwidth of every file anyone uploads. For small files that is a fine trade. For anything large it turns a cheap stateless service into an expensive one that is hard to scale (Making an Existing Service Stateless).
Direct-to-storage moves the bytes out of that path entirely. Your server's job shrinks to two small requests: sign a narrow permission before, verify and record after. The cost is a second origin in the flow, a cross-origin configuration on the bucket, and the fact that the actual transfer happens somewhere your application logs cannot see.
A user is uploading a file. What path do the bytes take?
when Small files, low volume, or the server genuinely must inspect or transform the content before it is stored anywhere.
cost Server memory and connection time proportional to file size and count; body-size and timeout limits become product limits; bandwidth on your bill twice (File Uploads Through the Backend).
when Anything large, anything high-volume, anything where the server does not need the bytes in the request path. The default for media.
cost A signing endpoint, a bucket cross-origin configuration, a separate completion step, and transfers you cannot observe from your own logs (Presigned URLs).
when Large files, unreliable networks, or a flow the user is expected to leave and come back to.
cost Part tracking, per-part retry, a resumable session that survives a reload, and cleanup for abandoned parts (Storage Lifecycle: Hot, Warm, Archive, Delete).
when Users are geographically far from the storage region and upload latency is dominated by distance.
cost Another component, another configuration, and another place a cross-origin misconfiguration can hide (CDN Delivery).
when The content already lives somewhere the user can grant access to, and a copy would only go stale.
cost An authorization integration, plus the possibility that the source disappears later.
Per-file state, and the controls that follow from it
Once each file has its own state, the interface almost writes itself: a list, one row per file, each row carrying a name, a preview, a status, a progress indicator while uploading, an error with a reason when it fails, and a control appropriate to its current state — cancel while running, retry when failed, remove when done. The thing that makes this feel complete rather than fussy is that a partial failure is now recoverable in place.
It is also the moment the accessibility requirements become concrete, because "announce progress" is meaningless without knowing which file is being announced. The specification below is what a screen-reader user needs in order to know that four of their five photos uploaded and the fifth was rejected because it was too large.
semantics A real <input type="file"> labelled by the visible drop zone. The queue is a ul, one li per file. Each progress indicator is a progress element or role="progressbar" with aria-valuenow/aria-valuemin/aria-valuemax and an accessible name containing the filename. A polite live region carries status summaries; per-file errors are associated with their row via aria-describedby.
| Tab | Reaches the file input, then each file row's controls in list order. |
| Enter / Space on the input or its label | Opens the system file picker — the keyboard-operable equivalent of dropping files. |
| Enter / Space on Cancel | Aborts that file's upload. The button's accessible name names the file. |
| Enter / Space on Retry | Restarts one failed file, without touching the others. |
| Enter / Space on Remove | Removes the row, revokes its object URL, and moves focus to the next row. |
- — Focus never moves on its own when a file finishes, fails, or a preview finishes decoding.
- — Removing or cancelling a row moves focus to the next row's first control, or back to the file input if the list is now empty — never to the document body.
- — After the picker closes, focus returns to the input, and the newly added files are announced rather than focused.
- — On selection: "3 files added" — plus, immediately, any that failed client-side validation and why.
- — On completion of each file, politely: "report.pdf uploaded. 3 of 5 complete."
- — On failure, politely and specifically: "invoice.tiff failed: file type not supported."
- — Progress at intervals, not continuously. Every increment is unlistenable; nothing at all is uninformative.
usually broken by The pattern is broken in three predictable ways: hiding the real input with display: none behind a styled drop zone, which removes the only keyboard path; announcing every progress increment into a live region, which produces unbroken speech that drowns out everything else on the page; and reporting failures as one form-level message — "some files failed" — which is unactionable when the user cannot see which row is red.
1type UploadItem = {2 id: string // client-generated; doubles as the idempotency key3 file: File4 previewUrl?: string // createObjectURL — must be revoked5 state:6 | { kind: 'queued' }7 | { kind: 'validating' }8 | { kind: 'uploading'; sent: number; total: number; abort: AbortController }9 | { kind: 'processing' } // bytes arrived; server is verifying10 | { kind: 'done'; key: string }11 | { kind: 'failed'; reason: FailReason; retryable: boolean }12 | { kind: 'cancelled' }13}14 15type FailReason =16 | 'too-large' | 'wrong-type' // caught client-side, as a courtesy17 | 'rejected' // the server\'s verdict, which is the real one18 | 'signature-expired' | 'network' // retryable19 | 'read-failed' // the file moved on disk after selection20 21// Progress needs a transport that reports it. fetch() does not report22// upload progress; XHR does, and is still the right tool for this job.23function put(item: UploadItem, url: string, onProgress: (sent: number) => void) {24 const xhr = new XMLHttpRequest()25 xhr.upload.onprogress = (e) => onProgress(e.loaded)26 item.state.kind === 'uploading' &&27 item.state.abort.signal.addEventListener('abort', () => xhr.abort())28 xhr.open('PUT', url)29 xhr.setRequestHeader('Content-Type', item.file.type || 'application/octet-stream')30 xhr.send(item.file)31 return xhr32}Two states earn their place and are usually missing. processing is the gap between the last byte and acceptance — without it the bar sits full and the user leaves. read-failed is the file that moved on disk after it was picked, which is a real error with a real message, not a network failure.
A pre-signed URL is a capability, not a link
The reason direct upload is safe at all is that the browser is never given credentials — it is given a signature that encodes exactly one permitted operation. The security of the whole design is therefore the narrowness of that signature, and the failure mode is entirely predictable: a signature that permits more than one upload is a write credential handed to an untrusted runtime, and it will be found, because everything in the network panel is visible to the user.
The four constraints are the key, the method, the content constraints and the expiry, and each one closes a specific hole. Pinning the key stops a user overwriting someone else's object. Pinning the method stops a read permission being handed out with a write. Pinning content type and maximum length stops an image upload becoming an unbounded write of arbitrary content. And a short expiry bounds how long a leaked signature is worth anything — which is the weakest of the four, and the one people rely on most.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Signature scoped to the bucket, not the key | Nothing — until someone notices they can overwrite any object | The signed policy did not pin the object key | Server generates the key; the signature permits that key and that method only (Object-Level Authorization). |
| No content-length condition | A storage bill, or a bucket full of arbitrary content | The signature permits a write of any size and any type | Sign a maximum length and a required content type; re-verify with a HEAD after completion. |
| Signature expires mid-transfer | Large uploads fail near the end, small ones never fail | Expiry shorter than a realistic transfer on a slow uplink | Size the expiry to the file and the connection, or switch to chunked upload where each part is signed separately. |
| Bucket cross-origin policy missing | The PUT fails with no useful detail in the console | The storage origin did not permit the request from your origin, so the browser withheld the response | Configure the allowed origin, method and headers on the bucket; test it as a deployment check (CORS). |
| Response lost after a successful PUT | A retry creates a second object | At-least-once delivery with a client-generated retry | Reuse the same object key and idempotency id on retry, so a repeat is an overwrite of the same object rather than a new one (Idempotency Keys: The Mechanism). |
| File moved on disk after selection | The read throws, often minutes into the flow | A File is a handle, not a copy | Catch it as its own error with its own message — "the file could not be read; it may have moved" — not as a network failure. |
| User content served inline from your origin | An uploaded SVG or HTML file executes with your origin's authority | Serving untrusted content same-origin with a renderable content type | Serve user content from a separate origin, with a server-chosen content type and a download disposition (Cross-Site Scripting). |
| Tab backgrounded on mobile | Uploads stall or vanish; the user returns to an ambiguous state | The page was suspended or discarded | Make uploads resumable, persist per-file state, and reconcile against the server on return (Offline UX). |
1POST /api/uploads HTTP/1.12Content-Type: application/json3 4{ "filename": "holiday.jpg", "contentType": "image/jpeg", "size": 3145728 }5 6---7 8HTTP/1.1 200 OK9{10 // The KEY IS CHOSEN BY THE SERVER. The client\'s filename is11 // display metadata; it never becomes a path.12 "key": "u/4f1c/2026/01/9b2e7a41.jpg",13 "url": "https://store.example.net/u/4f1c/2026/01/9b2e7a41.jpg?X-Sig=…",14 "method": "PUT",15 "requiredHeaders": { "Content-Type": "image/jpeg" },16 "maxBytes": 5242880,17 "expiresIn": 60018}19 20---21 22# The browser can do exactly this and nothing else:23PUT /u/4f1c/2026/01/9b2e7a41.jpg?X-Sig=… HTTP/1.124Host: store.example.net25Content-Type: image/jpeg26Content-Length: 314572827 28---29 30# Then the client says it is done — which proves nothing.31POST /api/uploads/9b2e7a41/complete32# The server does a HEAD on the object, checks it exists, checks the33# real size, sniffs the real content type, and only then records a row.The client-supplied size in the first request is a hint used to reject obvious nonsense early and to set maxBytes. It is not trusted: the signature enforces the limit, and the server re-checks the object it actually finds (Transport Validation).
How to build it
Most important first.
- Model each file as its own state machine:
queued → validating → uploading(progress) → succeeded | failed(reason) | cancelled. Everything else in this lesson follows from having that state per file rather than per form. - Give every file its own progress and its own controls — cancel, retry, remove — with the filename in the accessible name. A single aggregate bar cannot express "three done, one failed, one still going".
- Never let one file take down the others, or the form. Upload files independently of the form submission; the form carries references to completed uploads, not the bytes (Native Forms First).
- Treat client validation as a courtesy. Check type and size immediately so the user gets instant feedback instead of a wasted upload — and validate again on the server, from the content, because the client's claims are unverifiable (Errors People Can Actually Perceive).
- Preview with object URLs and revoke them, on removal and on unmount. Downscale before upload when the destination does not need the original, and do it off the main thread.
- Use direct-to-storage with a pre-signed URL for anything large. Bytes never touch your application server; it signs, then verifies and records. This is the single biggest scaling decision in the lesson (Choosing an Upload Path).
- Chunk and resume for large files or unreliable networks, so a failure costs one part rather than the whole transfer (Retries, and the Duplicate Order).
- Bound concurrency. Two or three files in flight, the rest queued, so the uplink is not divided into uselessly thin streams and the rest of the app still works (Bounding Concurrency).
- Drag-and-drop is an enhancement over a real file input, never a replacement. The input is what makes the control keyboard operable and exposed to assistive technology at all.
- Distinguish "bytes transferred" from "accepted". After the last byte there is usually server-side verification or processing, and the UI must say which one it is waiting for (The Async Job Pattern).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Upload progress must be perceivable without watching a bar. Give the progress element an accessible name that includes the filename, and keep
aria-valuenowcurrent — but do not announce every increment, which produces a continuous stream of speech that makes the page unusable (Live Regions and Announcement). - Announce at meaningful boundaries instead: started, roughly at intervals, complete, failed. A polite live region carrying "report.pdf uploaded, 3 of 5 complete" is worth more than a bar that never speaks.
- A cancel control must exist per file and be reachable by keyboard, with an accessible name that identifies which file it cancels — "Cancel upload of report.pdf", not "Cancel" repeated five times (Keyboard Operability).
- A per-file error must be announced and programmatically associated with that file's row, not shown as a single form-level message. "One file failed" is not an actionable statement when there are five (Errors People Can Actually Perceive).
- When a file row is removed, move focus deliberately — to the next row, or to the file input — rather than letting it fall to the document body, which silently returns a keyboard user to the top of the page (Focus Management).
- Drag-and-drop is not keyboard operable and is difficult with motor impairments or a screen magnifier. The visible drop zone must be a
labelfor a real<input type="file">, so the same action is available by keyboard, by pointer and by voice control (Semantics Before ARIA). - Do not hide the input with
display: none— hide it with a visually-hidden technique that keeps it focusable and exposed, or use alabelthat programmatically activates it.
What can go wrong
- Object URLs never revoked, so the tab's memory grows with every file the user previews and never shrinks.
- A cancel that races completion: the client reports "cancelled" while the server has already stored the object. The UI is now lying about a state it cannot observe.
- A retry after a lost response re-uploads a file that actually succeeded, producing a duplicate — unless the upload carries an idempotency key the server can recognise (Idempotency Keys: The Mechanism).
- A pre-signed URL that is too broad: any key in the bucket, any content type, no size limit, an expiry measured in hours. That is not an upload permission, it is a write credential in a browser.
- A pre-signed URL expiring mid-upload of a large file, which fails at whatever percentage it had reached and cannot be resumed with the same signature.
- Progress reaching completion and staying there while the server verifies, scans or transcodes. Users read a full bar as "done" and navigate away (Loading, Error, Empty — The States You Did Not Render).
- The storage bucket's cross-origin configuration is missing or wrong, so the direct upload fails in the browser with an error that says nothing useful about the actual cause (CORS).
- The tab is backgrounded on a phone and the upload is suspended or the page discarded, so a "successful" upload never completes and nothing tells the user.
- The retry mitigation itself becomes the failure: an automatic retry loop on a file the server will always reject burns the user's data allowance and never surfaces the real reason.
- A cancel arriving after the last byte, so the object exists but the UI says cancelled.
- A retry racing an original whose response was merely lost, producing two objects unless an idempotency key deduplicates them (Five Components, One Request).
- A pre-signed URL expiring mid-transfer, failing an upload that was otherwise healthy.
- The completion notification reaching your server before the object is readable, so verification fails for a file that is genuinely there.
- Two files with the same name uploaded concurrently, colliding if the storage key was derived from the name rather than generated.
- The user navigating away or the tab being discarded mid-upload, with no reliable event in which to record what had already succeeded (History and Navigation).
- Scope the pre-signed URL to exactly one upload: one object key that your server chose, one HTTP method, a required content type, a maximum content length, and the shortest expiry that a realistic upload can finish within. A signature missing any of those is broader than it looks (Presigned URLs).
- Everything the client says about the file is a claim. The name, the reported MIME type and even the size are attacker-controlled. The server determines the real type from the content and enforces the limit itself (File Upload Security).
- The filename is untrusted input. Never use it as a storage key, never interpolate it into a path, and never echo it into HTML unescaped. Generate the key server-side and keep the original name as a display-only property (Path Traversal).
- Serving user content from your own origin gives that content your origin's authority. An uploaded HTML file, or an SVG — which is a document that can contain script — becomes same-origin script if you serve it back inline. Serve user content from a separate origin, with a content type you chose and a disposition that does not render it inline (The Same-Origin Policy).
- Rendering an untrusted SVG preview is an injection sink. Preview it as an image with a content type you control, or rasterise it; never inject it into the DOM as markup (Sanitization and Trusted HTML).
- Enforce size limits where the bytes land. A pre-signed URL without a content-length condition is an unbounded write to your storage account, billed to you.
- The completion callback is also untrusted. A client saying "upload 42 is finished" proves nothing; the server verifies the object exists, checks its size and type, and only then records it (The Trust Boundary).
- "Client validation is validation." It is feedback. Anything enforced only in the browser is enforced by nobody (What the Frontend Is Responsible For in Auth).
- "
fetchgives me progress." It gives you *download* progress via the response stream. Upload progress needsXMLHttpRequest's upload events or a streamed request body where the browser supports it. - "A pre-signed URL is safe because it expires." Expiry is one of four constraints. A short-lived permission to write any object in the bucket is still a permission to write any object in the bucket.
- "The upload finished when the bar filled." The bytes arrived. Verification, scanning and processing come after, and the UI should say so (The Async Job Pattern).
- "Drag-and-drop is the interface." It is an alternative path for one input method. The
<input>is the interface. - "One progress bar is enough." Not for five files, and five files is the normal case.
- "Cancel means nothing was stored." Cancel means your client stopped sending. What exists on the other side is the storage layer's answer, not yours.
Measuring it, and what changes in the field
- Success rate segmented by file-size band and by connection type. Uploads fail in a pattern, and the pattern is almost always "large files on mobile uplinks".
- The distribution of failure reasons — rejected by validation, network, expired signature, cancelled by the user — because the responses to those four are entirely different (Network Failures Only the Client Can See).
- Cancellation rate and where in the transfer it happens. Early cancellations usually mean the file picker gave no feedback; late ones usually mean it was taking too long to be worth it.
- Time from the last byte to confirmed acceptance, which is the "stuck at full" window and is invisible in any server-side timing.
- Retry count per successful upload, and the duplicate rate at the storage layer, which together tell you whether idempotency is actually working (Retries, and the Duplicate Order).
- Per-file abandonment inside a multi-file selection: how often a user gives up after one of several fails (Analytics Events That Answer a Question).
- On a mobile connection the uplink is typically far worse than the downlink users are accustomed to, so an upload feels disproportionately slow relative to everything else in the app.
- On a phone, switching apps may suspend the page or discard it entirely. A long upload needs to be resumable or it needs to be honest about what happens when the user looks away.
- On a low-memory device, previews are the constraint rather than the transfer: several full-resolution decodes will be reclaimed under pressure, or take the tab down with them.
- With many files, concurrency is the whole design. Unbounded parallelism divides the uplink into streams that all finish at the end instead of one finishing early (Bounding Concurrency).
- Offline, an upload cannot be queued the way a small mutation can — the bytes have to live somewhere, and that somewhere is storage with a quota (The Offline Mutation Queue).
- In a long-lived tab, a pre-signed URL obtained early may already have expired by the time the user actually starts the upload (Long-Lived Clients and Version Skew).
- Direct-to-storage removes the bytes from your servers and adds a signing endpoint, a bucket cross-origin configuration, a completion step, and a class of failure that happens on an origin whose logs you do not read.
- Chunked resumable upload is a genuine step up in complexity: part tracking, part-level retries, a session that must be resumable across a reload, and a cleanup story for abandoned parts.
- Client-side downscaling saves upload time and the user's data, and costs main-thread work, battery, and fidelity you cannot recover later. It is the right default for avatars and the wrong one for evidence.
- Per-file controls are more UI than one bar, and they are what makes a partial failure recoverable instead of a restart.
- Bounding concurrency makes the total wall time for a batch a little longer and makes the individual completions arrive steadily, which is both more informative and less likely to starve the rest of the app.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALPer-file state, server-side validation and the pre-signed capability model hold across browsers and storage vendors. What differs between object stores is the exact signing scheme and the multipart API, not the shape of the design.
- BROWSER-SPECIFICUpload progress and streamed request bodies are the uneven part:
XMLHttpRequestupload progress is universally available, while streaming a request body throughfetchis not, and requires a protocol version the connection may not be using. Feature-detect and fall back toXMLHttpRequestrather than assuming the newer API. - DEVICE-SPECIFICMobile changes the constraint set rather than the design: a much slower uplink, aggressive suspension of backgrounded pages, camera capture producing large files in formats that may need conversion, and far less memory for previews. A design that only works on a desktop with a fast connection is not a smaller version of the right one.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — exercising an upload flow against a slow, lossy uplink and a suspended tab, which is the only way this class of bug is ever found before users find it.