A 100 MB upload through your API server holds a connection, memory, and a worker for the whole transfer. With a few users uploading at once, your app feels slow even though nothing is wrong with your code.
The common fix is to let the browser send the file straight to cloud storage (S3, Cloudflare R2, and similar). The backend stops carrying the bytes and only decides who may upload what.
This post covers the frontend side of that flow: browser APIs, a React implementation, progress, cancellation, validation, and the mistakes that break most first attempts. The code is illustrative, so test it against your storage provider.
Traditionally, a file travels through your application server:
Browser → Backend API → Cloud Storage
With direct-to-cloud uploads, the browser talks to storage itself:
Browser ──────────────→ Cloud Storage
│ │
└── Backend authorizes ────┘
The backend still controls permissions and verifies the result. It just doesn't proxy the file.
1. The Browser Already Gives You the Tools
You don't need a library to start handling files. These built-in APIs cover most of the upload experience:
| API | What it does |
|---|---|
File |
Represents a selected file |
FileList |
Provides access to selected files |
URL.createObjectURL() |
Creates a local URL for previews |
XMLHttpRequest |
Supports upload progress events |
AbortController |
Cancels supported network requests |
FormData |
Packages files for multipart form submissions |
A good example is the image preview. Selecting an image and showing it doesn't require uploading anything.
Before: this works, but it creates a new object URL on every render and never releases it.
import { useEffect, useState } from "react";
function ImagePreview() {
const [file, setFile] = useState(null);
useEffect(() => {
if (!file) return;
const previewUrl = URL.createObjectURL(file);
return () => URL.revokeObjectURL(previewUrl);
}, [file]);
return (
<div>
<input
type="file"
accept="image/*"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
{file && (
// New object URL on every render, and the one above is never used
<img src={URL.createObjectURL(file)} alt="Selected image preview" />
)}
</div>
);
}
After: one URL per file, stored in state and revoked on cleanup.
import { useEffect, useState } from "react";
function ImagePreview() {
const [file, setFile] = useState(null);
const [previewUrl, setPreviewUrl] = useState("");
useEffect(() => {
if (!file) {
setPreviewUrl("");
return;
}
const url = URL.createObjectURL(file);
setPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
return (
<div>
<input
type="file"
accept="image/*"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
{previewUrl && <img src={previewUrl} alt="Selected image preview" />}
</div>
);
}
The preview stays local until the user actually uploads.
2. What Changes With Direct Uploads?
In a conventional setup, the frontend sends the file to an application endpoint, and the server forwards it to storage:
await fetch("/api/upload", {
method: "POST",
body: formData,
});
With direct uploads, the flow has four steps:
1. React requests upload authorization
2. Backend returns a signed upload URL
3. React uploads the file directly to storage
4. React reports completion to the backend
The browser is now responsible for the transfer. That reduces load on your application server, especially when many users upload large files. It also means the frontend has to handle failures, progress, and completion properly.
3. Implementing Direct Uploads in React
Start by separating the upload logic from the UI. Assume the backend exposes an endpoint that returns a short-lived signed URL and an asset identifier. The exact response depends on your storage provider.
async function getUploadAuthorization(file) {
const response = await fetch("/api/uploads/sign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
size: file.size,
}),
});
if (!response.ok) {
throw new Error("Could not authorize upload");
}
return response.json(); // { uploadUrl, assetId }
}
This is the simplest version of the upload itself. Section 4 swaps the PUT for XHR so we get progress.
async function uploadFile(file) {
const { uploadUrl, assetId } = await getUploadAuthorization(file);
const response = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
if (!response.ok) {
throw new Error("Upload failed");
}
const completion = await fetch(`/api/uploads/${assetId}/complete`, {
method: "POST",
});
if (!completion.ok) {
throw new Error("Could not finalize upload");
}
return completion.json();
}
The HTTP method, headers, and completion flow must match your storage provider's requirements. The backend must verify the uploaded object before trusting it.
Notice what the frontend doesn't need: storage credentials. It receives permission for one particular upload, not access to the storage account.
4. Upload Progress and Cancel
A button that says "Uploading..." doesn't tell users whether the upload is moving, stalled, or almost done.
fetch() doesn't currently expose a standard upload-progress event. For progress reporting, XMLHttpRequest is the practical option. This version also accepts an AbortSignal so the caller can cancel:
function uploadWithProgress(uploadUrl, file, onProgress, signal) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", uploadUrl);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) return;
onProgress(Math.round((event.loaded / event.total) * 100));
};
xhr.onload = () =>
xhr.status >= 200 && xhr.status < 300
? resolve()
: reject(new Error(`Upload failed: ${xhr.status}`));
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.onabort = () =>
reject(new DOMException("Upload cancelled", "AbortError"));
if (signal) {
if (signal.aborted) return xhr.onabort();
signal.addEventListener("abort", () => xhr.abort(), { once: true });
}
xhr.send(file);
});
}
And in a component:
const [progress, setProgress] = useState(0);
const controllerRef = useRef(null);
async function handleUpload(file, uploadUrl) {
setProgress(0);
controllerRef.current = new AbortController();
try {
await uploadWithProgress(
uploadUrl,
file,
setProgress,
controllerRef.current.signal
);
} catch (error) {
if (error.name !== "AbortError") console.error(error);
}
}
const cancel = () => controllerRef.current?.abort();
One distinction worth remembering: 100% progress means the browser finished sending the bytes. It doesn't mean the storage provider has finished processing the file, or that your application has finalized it.
Cancelling also only stops the browser's request. If the object had already landed in the bucket, it stays there, so you may need a cleanup job for orphaned uploads.
5. Make the Upload UI Resilient
Networks fail, signed URLs expire, users close tabs, and files get rejected. Model those cases deliberately.
The happy path:
idle → selected → authorizing → uploading → finalizing → success
And the failure paths:
uploading ──→ failed ──→ retry
│
└──────→ cancelled
A small state shape is enough to start:
const initialUploadState = {
status: "idle",
progress: 0,
error: null,
};
For a production component, I'd cover these:
- Disable duplicate submissions while an upload is active
- Show progress and a clear cancel action
- Keep the selected file after a recoverable failure
- Request a new signed URL on every retry, so expired URLs stop being a problem
- Avoid updating state after the component unmounts
- Announce status changes to screen readers (for example with
aria-live="polite")
6. File Validation: What the Frontend Can and Cannot Do
Rejecting obviously invalid files before asking for authorization gives users instant feedback:
function validateFile(file) {
const maxSize = 5 * 1024 * 1024;
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
if (!allowedTypes.includes(file.type)) {
return "Please select a JPEG, PNG, or WebP image.";
}
if (file.size > maxSize) {
return "The image must be smaller than 5 MB.";
}
return null;
}
But frontend validation is not a security boundary. A user can bypass it or modify the request. The backend and storage policy must enforce the restrictions independently, and file contents may need inspection after upload.
Frontend validation improves usability. Server-side enforcement establishes trust. You need both.
7. Mistakes That Break the First Attempt
CORS on the bucket. The browser sends a preflight request to the storage domain. Without a CORS rule that allows your origin and PUT, the upload fails before it starts. For S3, a minimal rule looks like this (use your real origin):
[
{
"AllowedOrigins": ["http://localhost:5173"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]
Content-Type doesn't match what was signed. If the backend signed the URL for image/png and the browser sends something else, S3 typically rejects it with SignatureDoesNotMatch. Send the same type you asked the backend to sign.
Trusting file.type. The browser derives it from the file extension, not the file's contents. Use it for UI feedback, and validate for real on the server or after upload.
Expecting a presigned PUT to enforce size limits. It generally can't. On S3, a presigned POST with policy conditions can, but support differs between providers, so check yours.
Skipping the completion check. The /complete endpoint should verify that the object exists (for example with a HEAD request) before saving metadata. Don't just trust that the client says it finished.
8. Multiple Files and Large Uploads
Once the basic flow works, a few more challenges show up.
Multiple files. Track each file separately, with its own status, progress, and error. A single global progress value falls apart quickly.
Concurrency. Uploading everything at once can saturate the browser's connections and hurt slow networks. A small upload queue gives you control.
Large files. Multipart or resumable uploads help recover from network failures. These usually need coordination with your storage provider's SDK or API.
If you don't want to hand-roll all of this, Uppy or your storage provider's SDK already handle chunking, retries, and queues. Building it once yourself is still a good way to understand what they do.
9. Is Direct-to-Cloud Always Better?
No. Direct uploads shine when an app handles lots of media or large files. A small app with a simple upload endpoint may not need the extra complexity.
| Requirement | What to consider |
|---|---|
| Simple form with small attachments | A conventional backend endpoint may be sufficient |
| Large images or videos | Direct uploads can reduce backend transfer load |
| Live progress indicators | Use upload-progress-capable APIs or a suitable SDK |
| Unreliable networks | Consider retries and resumable uploads |
| Sensitive documents | Use strict authorization, private storage, and appropriate inspection |
| Malware scanning or moderation | Scan after upload before exposing the file, or proxy through the server |
| Many concurrent uploads | Direct uploads plus a controlled client-side queue |
Choose the architecture based on your actual requirements, not on what's newer.
Checklist
✓ Bucket CORS allows your origin and PUT
✓ Content-Type sent equals Content-Type signed
✓ Fresh signed URL on every attempt
✓ Progress via XHR, cancel via AbortSignal
✓ Server-side validation, not just validateFile
✓ Completion step verifies the object exists
✓ Plan for orphaned objects after cancelled uploads
Takeaway
Direct uploads don't remove the backend. They change its job from carrying bytes to granting and verifying permission. The frontend takes on the transfer, which means progress, cancel, retry, and error states become your responsibility.
What does your upload flow look like today: a traditional backend endpoint, direct-to-cloud, or a provider SDK? I'd like to hear what made you choose it, especially if you hit problems I haven't covered here.
Top comments (1)
tr.ee/dev-to