Uploading Files Via JS Fetch API: Complete Guide
Learn how to upload files with the Fetch API using FormData. Handle single and multiple file uploads, track upload progress, set file size limits, and manage server responses.
Uploading files via fetch means sending a POST request with a FormData body. The browser packages the file into a multipart/form-data request, sets the correct headers automatically, and sends it to the server.
This matters because a raw file cannot be stringified into JSON the way ordinary form fields can. The multipart format exists specifically to carry binary data alongside regular text fields in one request.
A small shared helper checks the response status before handing back the parsed body, so every upload function below can reuse it instead of repeating the same check.
async function parseUploadResponse(response) {
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
}
return response.json();
}The upload function itself only needs to build the FormData, send the request, and hand the response to that helper.
async function uploadFile(file) {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", { method: "POST", body: formData });
return parseUploadResponse(response);
}Wiring that function to a file input reads the selected file and uploads it as soon as the user picks one, with the try...catch around the call keeping a failed upload from becoming an unhandled promise rejection.
document.getElementById("fileInput").addEventListener("change", async (event) => {
const file = event.target.files[0];
if (!file) return;
try {
const result = await uploadFile(file);
console.log("Uploaded:", result.url);
} catch (err) {
console.error(err.message);
}
});The key points are to create a FormData, append the file with a field name the server expects, and never set Content-Type manually. The browser handles the multipart encoding on its own, choosing a random boundary string and using it to separate the file's raw bytes from any other fields in the same request.
Uploading a file with extra form fields
Most uploads need more than just the file itself, such as an owner ID or a bit of metadata that describes what the file is for. You can append additional fields to the same FormData alongside the file, and the server reads them exactly the way it would read fields from a normal HTML form submission.
async function uploadAvatar(file, userId) {
const formData = new FormData();
formData.append("avatar", file);
formData.append("userId", userId);
formData.append("crop", JSON.stringify({ x: 0, y: 0, width: 200, height: 200 }));
const response = await fetch("/api/users/avatar", { method: "POST", body: formData });
return parseUploadResponse(response);
}String values are sent as plain text fields. If you need to send structured data like the crop coordinates above, stringify it first, since FormData only stores strings and Blobs, not nested objects. The server parses each field individually from the multipart body, so on the server side the crop field arrives as a plain string that needs its own JSON.parse call before it becomes usable data again.
Uploading multiple files
Append each file to the same FormData using the same field name. The server receives them as an array instead of a single value, since FormData supports multiple entries sharing one key by design, unlike a plain object where a later assignment would overwrite an earlier one.
async function uploadMultipleFiles(files) {
const formData = new FormData();
for (const file of files) {
formData.append("photos", file);
}
const response = await fetch("/api/photos/upload", { method: "POST", body: formData });
return parseUploadResponse(response);
}A file input with the multiple attribute already gives you an array-like list of files, so converting it to a real array is the only extra step needed before calling the function.
const input = document.getElementById("photoInput");
input.addEventListener("change", async (event) => {
const files = Array.from(event.target.files);
const result = await uploadMultipleFiles(files);
console.log(`Uploaded ${result.count} files`);
});All files go into the same multipart request under the same field name. The server sees photos as an array of files rather than needing a separate request per file, which keeps the upload atomic from the client's point of view even though the server may still process each file independently.
Validating files before uploading
Always validate on the client before sending. It gives the user immediate feedback and avoids wasting bandwidth on files the server will reject anyway.
Client-side validation is a convenience, not a security boundary, since a user can bypass it entirely. The server still needs to enforce the same size and type limits on its own.
function validateFile(file, options = {}) {
const {
maxSize = 5 * 1024 * 1024, // 5 MB default
allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"],
} = options;
if (!file) return { valid: false, error: "No file selected." };
if (file.size > maxSize) return { valid: false, error: sizeError(file.size, maxSize) };
if (!allowedTypes.includes(file.type)) return { valid: false, error: `File type ${file.type} is not allowed.` };
return { valid: true };
}The size error message needs a bit of formatting, so it lives in its own small function instead of being built inline.
function sizeError(actualBytes, maxBytes) {
const actualMB = (actualBytes / (1024 * 1024)).toFixed(1);
const maxMB = (maxBytes / (1024 * 1024)).toFixed(0);
return `File is ${actualMB} MB. Maximum is ${maxMB} MB.`;
}The function checks the size property in bytes and the type property for the MIME type, returning a result object instead of throwing, so the caller can decide how to show the message to the user.
Keep in mind that the browser reports the type based on the file's extension or its own sniffing, not by inspecting the actual file contents, so a renamed file can slip past a type check that only compares MIME strings. Servers that need real security guarantees, rather than just a better user experience, inspect the file's actual bytes rather than trusting anything the client reports about itself.
function validateSelectedFile(file) {
return validateFile(file, {
maxSize: 2 * 1024 * 1024,
allowedTypes: ["image/png", "image/jpeg"],
});
}The event handler itself just wires the file input to that validator and only calls upload once the file passes.
async function handleFileSelect(event) {
const file = event.target.files[0];
const validation = validateSelectedFile(file);
if (!validation.valid) {
alert(validation.error);
return;
}
const result = await uploadFile(file);
console.log("Uploaded:", result.url);
}Calling validateFile with custom limits overrides the defaults for that one call, which is useful when different parts of an app accept different file types, such as a 2 MB avatar limit versus a larger limit for document uploads. The file's name property also gives you the original filename, which you can validate for extension if the MIME type alone is not specific enough.
Sending files as a Blob or base64
For non-form uploads, you can read a file as a Blob or a base64 data URL and send it in a JSON body instead of using FormData at all. This is common when an API expects a single raw file per request rather than a multipart body, such as an endpoint dedicated entirely to file storage. The simplest option sends the raw file bytes directly, since a File object already extends Blob and fetch accepts a Blob as a body on its own without any wrapping.
// Send the raw file bytes with a custom Content-Type
async function uploadRawFile(file) {
const response = await fetch("/api/files", {
method: "POST",
headers: {
"Content-Type": file.type,
"X-Filename": encodeURIComponent(file.name),
},
body: file,
});
return response.json();
}Some APIs instead expect the file encoded as base64 text inside a JSON body. Reading the file that way needs a FileReader, wrapped in a promise so it can be awaited like any other async operation.
function readFileAsBase64(file) {
const reader = new FileReader();
return new Promise((resolve, reject) => {
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}From there, sending it is just a normal JSON POST, the kind used for any API call that takes a JSON body, except this body carries the filename, content type, and the base64 string as three ordinary fields. The raw Blob or File approach shown above is simpler and more efficient. Base64 encoding increases the payload size by about 33% and requires the server to decode it, so use base64 only when the API you are integrating with actually requires it.
Tracking upload progress
The Fetch API does not provide upload progress events. For progress bars, you have two options:
Option 1: Use XMLHttpRequest for the upload. Its upload progress event fires repeatedly with loaded and total byte counts as the request streams to the server, which fetch simply does not expose. Wrapping the old callback-based API in a promise lets the rest of the code still use async/await.
function attachCompletionHandlers(xhr, resolve, reject) {
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`HTTP ${xhr.status}`));
}
});
xhr.addEventListener("error", () => reject(new Error("Upload failed")));
}The main function wires the progress event to the caller's callback, attaches the helper above for success and failure, and finally sends the request.
function attachProgressHandler(xhr, onProgress) {
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
});
}With both handlers factored out, the main function is left with just the setup and the send call, which makes the overall flow easier to follow than one long function juggling three callbacks at once.
function uploadWithProgress(file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
attachProgressHandler(xhr, onProgress);
attachCompletionHandlers(xhr, resolve, reject);
const formData = new FormData();
formData.append("file", file);
xhr.open("POST", "/api/upload");
xhr.send(formData);
});
}Calling it looks just like any other async upload function, except it also reports a running percentage as the bytes go out.
await uploadWithProgress(file, (percent) => {
console.log(`Upload progress: ${percent}%`);
});Option 2: Use fetch with a ReadableStream. This is more complex and only works if you read the file in chunks yourself, tracking how many bytes you have pushed so far as you go. For most use cases, XMLHttpRequest is the pragmatic choice for progress tracking, even in an otherwise fetch-based codebase, since it needs far less custom code to get the same result.
Handling server responses after upload
The server typically returns the uploaded file's URL, an ID, or metadata describing what was stored. Handle both success and error responses, starting with the request itself.
async function sendUpload(file) {
const formData = new FormData();
formData.append("file", file);
try {
return await fetch("/api/upload", { method: "POST", body: formData });
} catch (err) {
throw new Error("Could not connect to the server. Check your internet connection.");
}
}The processing function calls that helper, then works through the server status and the shape of the returned body before trusting any of it, since a resolved fetch promise only means the round trip completed, not that the upload itself was accepted.
async function uploadAndProcess(file) {
const response = await sendUpload(file);
const body = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(body?.error || `Server error (${response.status})`);
}
if (!body || !body.url) {
throw new Error("Server returned an unexpected response.");
}
return { url: body.url, filename: body.filename, size: body.size };
}Three distinct failure modes are covered here: network down, server rejected the file with a validation error, or the response format was unexpected. Each gets its own error message so the user knows what actually went wrong instead of a generic failure notice.
Treating these as separate cases matters in practice, because the right recovery action is different for each one. A network failure usually means retrying is worth it once the connection comes back, a validation error means the user needs to pick a different file, and an unexpected response usually points to a server bug that retrying will not fix.
Common file upload mistakes
Overriding Content-Type for FormData. This is the most common bug. Let the browser set the multipart boundary.
// Mistake
headers: { "Content-Type": "multipart/form-data" }
// Correct: omit Content-Type entirely
// The browser sets it with the boundary automaticallyNot handling the case where the user selects no file. The file input's files array can be empty. Always check before calling uploadFile.
Uploading enormous files without size checks. A user might accidentally select a 2 GB video file. Validate the file's size before sending.
Assuming the server response is always JSON. If the server returns an HTML error page on 500 errors, calling json on the response throws a SyntaxError instead of giving back data. Falling back to null when parsing fails handles unexpected response formats gracefully.
Forgetting to reset the file input after upload. If the user uploads the same file twice in a row, the change event may not fire the second time because the input's value never actually changed between the two selections. Setting input.value = "" right after a successful upload clears that stored value, so picking the same file again still triggers a fresh change event.
For the fundamentals of POST requests and FormData, see Handling Post Requests with JS Fetch API Guide. For cancelling in-progress uploads, see Canceling Fetch Requests in JavaScript: Full Guide.
Rune AI
Key Insights
- Use FormData to send files with fetch. Append the file and POST without setting Content-Type.
- Validate file size and type before uploading to give the user immediate feedback.
- The server receives files as multipart/form-data. Access them via request.files or equivalent.
- fetch does not support upload progress events. For progress bars, consider XMLHttpRequest instead.
- Handle both network errors (fetch rejects) and server errors (response.ok is false).
Frequently Asked Questions
Can I track upload progress with fetch?
How large a file can I upload with fetch?
Can I upload multiple files in one request?
Conclusion
Uploading files with fetch and FormData is straightforward for basic cases: create a FormData, append the file, and POST it. For production, always validate file size and type on the client first, show upload feedback, and handle both network errors and server validation errors.
More in this topic
Using Decorators for Logging in JS Architecture
Learn how JavaScript decorators wrap class methods to add logging without touching the original code, including setup with Babel and how execution order works.
JavaScript While Loop Explained: A Complete Guide
A while loop repeats code as long as a condition stays true. Learn the syntax, see practical examples, and understand when a while loop is the right choice over a for loop.
JavaScript Loops Tutorial: for, while, do while
Loops let JavaScript repeat code without writing it twice. Learn the three core loop types, what each one does, and when to choose one over another.