Skip to content
51studio
Tutorials

File uploads done right

By Mira Solway9 min read

File uploads look like a solved problem. They're a sleeper hard one. The default browser file picker plus a basic POST request handles the happy path: small file, good connection, no errors. It fails everywhere else.

The user on the train uploading a 200MB video as the train enters a tunnel. The user dragging a folder thinking they're dragging a file. The user uploading a PDF that's actually a virus. The user closing the tab halfway through. The user on iOS where the file is actually a HEIC photo Safari claims is a JPEG.

This post is the things that look optional and aren't. The patterns that handle real users without making the developer write a queue system from scratch.

What the default gets wrong

A naive file upload:

html
<input type="file" />
<button>Upload</button>
ts
const file = input.files[0];
await fetch('/api/upload', { method: 'POST', body: file });

This works for files under 10MB on a stable connection. Above that, or on a flaky network, it breaks in five different ways:

  • No progress indication. The user doesn't know if it's working.
  • No way to recover from a failed upload. They have to start over.
  • The server has to hold the entire file in memory.
  • The file passes through your server when it could go straight to storage.
  • There's no validation beyond what the browser does (which is approximately nothing).

For an internal tool with five users, this is fine. For anything customer-facing, it's not.

The five things that aren't optional

1. Progress UI

The user must see that the upload is happening, how far along, and how much is left. Without this, they assume the system is broken.

Implementation: XMLHttpRequest with progress events (yes, in 2026, XMLHttpRequest is still the cleanest way to get upload progress; fetch() doesn't give you upload progress without a streaming workaround).

ts
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
  if (e.lengthComputable) {
    const percent = (e.loaded / e.total) * 100;
    setProgress(percent);
  }
});
xhr.open('POST', '/api/upload');
xhr.send(file);

Show: percent complete, MB uploaded / MB total, an estimated time remaining for files over 10MB. The estimate gets calculated from the rate over the last few seconds; don't trust an estimate based on the first second of upload.

2. Resumable uploads for files over ~50MB

Anything over 50MB needs to survive a network interruption. The user shouldn't have to restart a 30-minute upload because their wifi blipped.

The standard for this is tus (the resumable upload protocol). tus-js-client on the front end, a tus-compatible endpoint on the back end (or a managed service like Uppy + Companion).

The simpler alternative: chunked uploads. Slice the file into 5MB chunks on the client. Upload each chunk separately. Track which chunks succeeded; retry the failed ones. After all chunks succeed, the server reassembles.

Cloudflare R2, AWS S3, and Google Cloud Storage all support multipart uploads natively, which is the same idea managed by the storage layer.

3. Direct upload to storage (skip the server)

For anything bigger than a few MB, upload directly to storage (S3, R2, GCS) instead of through your server.

Pattern:

  1. Client requests a presigned upload URL from your server.
  2. Server generates a presigned URL with a short expiration (15 minutes) and returns it.
  3. Client uploads directly to that URL.
  4. After upload, client tells the server the upload is done; server records the file's metadata.

Benefits:

  • Your server doesn't hold the file in memory.
  • Upload bandwidth doesn't go through your server.
  • Scales horizontally without backpressure.
  • The serverless function that generates the URL is fast and cheap.

The work to set this up is real (presigned URL generation, post-upload callback, error handling). But once it's in place, it handles uploads of any size without your server caring.

4. Validation, server-side and serious

Three layers of validation, in order of strictness:

Client-side: the file picker's accept attribute, basic size check. This is UX, not security. Users can bypass it. Don't rely on it.

Server-side metadata: when the upload completes, verify file size, MIME type from the server's perspective, file extension. Reject if mismatched.

Content sniffing: read the first few bytes of the file and confirm they match the claimed type. A .pdf should start with %PDF. A .jpg should start with FF D8 FF. Tools like file-type in Node.js do this. A file that claims to be a JPEG but isn't is a red flag.

Virus scanning: for any file that other users will see or download, run it through ClamAV or a managed service (Cloudmersive, VirusTotal API). This is non-negotiable for user-uploaded content. The 50ms it adds is worth it.

The order matters. Catch obvious issues at the cheap layers first. Only do virus scanning on files that survived size and content checks.

5. Error recovery

Specific errors and what to do with each:

  • Connection lost mid-upload: retry with exponential backoff. If using resumable upload, resume from the last successful chunk.
  • Server returned 5xx: retry once. If it fails again, surface the error to the user with a "try again" button.
  • File rejected by server (4xx): surface the reason. "File too large" / "Wrong type" / "Failed virus scan." Don't show "Upload failed" with no detail.
  • Tab closed during upload: nothing you can do mid-flight, but the partial upload should be cleaned up server-side (orphaned multipart uploads cost storage).
  • Multiple files, one fails: don't roll back the successful ones. Show clearly which one failed and let the user retry just that one.

Most "file upload broken" support tickets are actually "the error message was unhelpful." Specific errors with specific recovery paths catch most of these.

A walkthrough

A working setup for a typical web app:

Client:

  • File picker with accept and multiple attributes set appropriately.
  • On selection, validate size and type client-side (UX only).
  • Request presigned URLs for each file from /api/uploads/presign.
  • Upload each file via chunked PUT requests directly to S3/R2.
  • Show progress per file, with overall progress for multi-file selections.
  • On completion, POST to /api/uploads/confirm with the upload IDs.
  • Show success states, retry failed files individually.

Server:

  • /api/uploads/presign: validate the request (user is authenticated, has quota), generate presigned URL with 15-minute expiration, return.
  • /api/uploads/confirm: verify the file actually exists in storage, read first few bytes for content sniffing, queue a virus scan job, record metadata.
  • Background job for virus scanning: pull file from storage, scan, mark clean/quarantine. If quarantined, notify admin and delete from storage.

This is 300-500 lines of code total, plus the infra setup (S3 bucket, presign secret, virus scanner). It handles 99% of upload cases including the painful edge cases.

Edge cases worth knowing

iOS HEIC photos. When iOS users upload a photo, the file is HEIC by default. Your code might assume JPEG/PNG. Either accept HEIC and convert server-side (with sharp or imagemagick), or detect HEIC client-side and convert before upload (heic2any in the browser).

Drag-and-drop folders. Most browsers expose folder drag-and-drop via the webkitGetAsEntry API. Either support it explicitly (recursing through subfolders), or filter to files-only and show "folders not supported."

File picker on mobile. The mobile file picker is different from desktop. iOS shows camera, photo library, files. Test on real devices; the camera capture path is different from the file selection path.

Very large files (>1GB). Even resumable uploads strain at this size. Tell the user up front ("Files over 1GB may take significant time"). Show ETA clearly. Save progress to localStorage so a closed-tab recovery is possible.

Network throttling. Test the upload UI at 1Mbps and 100Kbps. The progress UI should still be useful at slow speeds. The default UI usually isn't.

What we stopped recommending

Patterns we've moved away from:

Uploading directly to your origin server. Was the default. Now use direct-to-storage for anything user-facing.

Using a single POST request for files over 50MB. Was acceptable on stable connections. Now use chunked or resumable upload.

Server-side virus scanning synchronously. Was simple. Now do it as a background job to keep the upload response fast.

Storing original filenames as-is. Filenames carry security risks (path traversal, encoding issues, length limits). Generate a UUID or hash on upload, store the original filename as metadata.

A checklist

  • Progress UI for any upload over 1MB.
  • Resumable or chunked for any file over 50MB.
  • Direct-to-storage for any file over 5MB.
  • Server-side metadata validation (size, type, extension).
  • Content sniffing for security-sensitive content.
  • Virus scanning for any user-shared content.
  • Specific error messages with recovery paths.
  • Tested on slow networks (throttle to 1Mbps).
  • Tested with HEIC photos from iOS.
  • Tested with multi-file drag-and-drop.

If you can tick all ten, your file upload handles the cases that actually break. Most teams ship apps that tick three.

If you're building a product where uploads are part of the core flow, see how we work on web apps. File uploads are one of the places we sweat the details.

Related articles