File Upload in React: The Input Is Easy, the File Is Not

Adding a file upload feels like a solved problem. You drop an <input type="file"> on the page, grab the file in an onChange, POST it to an endpoint. Ten minutes of work, and on your machine — with a 40KB avatar and localhost — it works flawlessly. You move on.
Then it meets reality. Someone uploads a two-gigabyte video from hotel wifi. Someone's connection drops at 90%. Someone picks a 500MB file and your server, which happily buffered the 40KB avatar into memory, tries to do the same with half a gigabyte and falls over. The file input was never the hard part. The hard part is that a file is a large, unreliable stream of bytes that has to travel from an untrusted browser, across a flaky network, to somewhere it can actually live — and every one of those words is a problem.
So this article isn't about the input element. It's about the thing underneath: moving real files, at real sizes, over real networks, without melting your server or lying to your user about what's happening.
The First Decision: Your Server Shouldn't Touch the File
Here's the instinct that causes most upload pain: the file goes browser → your server → storage. Your React app POSTs to your API, your API receives the bytes and forwards them to S3 or wherever. It's the obvious shape, and it's the wrong one for anything but tiny files.
Why it's wrong: your server now has to hold the file — in memory or on disk — while it passes through. A handful of users uploading large files at once and your server is doing nothing but shuttling bytes, burning memory and connection time on work that isn't yours. You've turned your application server into a file relay, and file relays are exactly what dedicated storage services already are, better than you.
The pattern that fixes it is presigned uploads, and it's worth understanding because it reshapes the whole flow. The file goes browser → storage directly, and your server's only job is to hand out permission. The client asks your API "I want to upload this file," your API asks the storage service for a short-lived, single-purpose URL, and hands that URL back. The browser then uploads straight to storage. Your bytes never touch your server.
// Your server: it never sees the file. It only grants permission. app.post('/api/uploads/sign', async (req, res) => { const { filename, contentType } = req.body; // Ask storage for a short-lived URL scoped to this one upload. const uploadUrl = await storage.createPresignedUrl({ key: `uploads/${crypto.randomUUID()}/${filename}`, contentType, expiresIn: 60, // seconds — the URL is useless after that }); res.json({ uploadUrl }); });
// The browser: uploads the bytes directly to storage, not to you. async function uploadFile(file: File) { const { uploadUrl } = await fetch('/api/uploads/sign', { method: 'POST', body: JSON.stringify({ filename: file.name, contentType: file.type }), }).then((r) => r.json()); await fetch(uploadUrl, { method: 'PUT', body: file }); // straight to storage }
Look at how the responsibilities split. Your server does a tiny, fast, cheap thing — validate the request, mint a scoped URL, respond. The multi-gigabyte transfer happens between the browser and a service built for exactly that. This is the same server-is-the-authority, client-does-the-work split from the auth and payments articles: your backend controls permission, but it doesn't do the heavy lifting it has no business doing.
The Second Reality: The Upload Is a Process, Not a Request
Once the file is going straight to storage, the next thing to accept is that a large upload is not an event that happens — it's a process that unfolds over time, and your UI has to treat it that way.
A 40KB avatar uploads instantly, so you can pretend it's a normal request. A 500MB file takes a minute or more, and during that minute the user is staring at your interface wondering if it's working. If you show them a frozen spinner, they'll assume it hung and refresh — killing the upload. So progress isn't a nice-to-have here; it's the difference between a feature that works and one users abort halfway. You have to report how far along the bytes are:
function uploadWithProgress(url: string, file: File, onProgress: (pct: number) => void) { const xhr = new XMLHttpRequest(); xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)); }); xhr.open('PUT', url); xhr.send(file); }
That xhr.upload.progress event — one of the few times raw XMLHttpRequest still earns its place over fetch, which can't report upload progress — is what lets you draw a real progress bar. And a real progress bar changes user behavior: people wait for a bar that moves, and they abandon a spinner that doesn't. The technical work of measuring progress is small; the UX consequence of showing it is large.
The Third Reality: Big Files Need to Survive a Dropped Connection
Now the hardest layer, and the one that separates a toy from a product: what happens at 90% when the wifi blinks?
With a single PUT of the whole file, the answer is brutal — the upload fails and starts over from zero. For a 40KB file, who cares. For a 2GB file on a shaky connection, "start over from zero" may mean it never completes, because the odds of an uninterrupted multi-gigabyte transfer on bad wifi are genuinely low. The single-request model has a size ceiling baked in, and above it, uploads just don't finish.
The answer is chunked (multipart) uploads: split the file into pieces, upload each piece independently, and reassemble them in storage. Now a dropped connection costs you one chunk, not the whole file — you retry that chunk and continue. Uploads become resumable, and the size ceiling effectively disappears.
I want to be honest about the tradeoff, because this is where the "it's just an input" fantasy fully dies: chunking is genuinely more complex. You're tracking which chunks succeeded, retrying the ones that didn't, handling out-of-order completion, and telling storage to stitch them back together. This is real coordination logic, and it's exactly why you don't hand-roll it. Storage services expose multipart upload APIs, and libraries like Uppy wrap the whole thing — chunking, retries, progress, resumability — behind a component. The same lesson as every article in this series: the raw capability is a footgun, and the value is a library that owns the messy edges so you spend your attention on your product, not on retry bookkeeping.
Don't Forget: The File Is Untrusted
One security note that's too important to skip, because uploads are a classic attack surface. A file arriving from a browser is user input, and the same rule from the payments and auth articles applies: never trust it.
The client can claim a file is a 2MB image and actually send a 2GB executable. It can lie about the content type. So validation belongs on the boundary you control — size limits, allowed types, and ideally scanning — enforced when you mint the presigned URL and verified again on the storage side, never solely in the browser where a hostile user can bypass it. Client-side checks are for UX (tell the user "that's too big" before they wait); server-side checks are for safety. You need both, and you must never mistake the first for the second.
How I'd Approach It
Strip it to decisions:
- Your server hands out permission, not bytes. Use presigned URLs so the file goes browser → storage directly. Don't turn your API into a file relay.
- Treat the upload as a process. Show real progress from
xhr.upload. A moving bar is why users wait instead of refreshing. - Chunk large files. Multipart uploads make transfers resumable and remove the size ceiling. Don't hand-roll it — use the storage API via a library.
- The file is untrusted input. Validate size and type on the server boundary, not just in the browser. Client checks are UX; server checks are security.
The reason file upload is a rite of passage is that the easy version and the real version look identical right up until the file gets big or the network gets bad. The input, the POST, the success toast — all of that is the 10% you can write in your sleep. The 90% is respecting that you're moving a large, untrusted, interruptible thing across a connection you don't control, to a service that should be doing the heavy carrying instead of your server. Get that shape right — permission from your backend, bytes to real storage, progress and resumability for the user — and uploads stop being the feature that falls over in production.
Next, another piece of infrastructure that looks trivial and isn't: sending email from a React app — why "just send an email" is a backend problem wearing a frontend costume, and where the real work hides.
If you've got an upload that works for small files and dies on big ones, that's the single-request ceiling, and it's the most common upload bug there is. Tell me where it breaks — the server memory, the timeout, the 90% drop — and I'll tell you which layer is missing.