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.

8 min read

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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
// 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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
// Mistake
headers: { "Content-Type": "multipart/form-data" }
 
// Correct: omit Content-Type entirely
// The browser sets it with the boundary automatically

Not 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

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).
RunePowered by Rune AI

Frequently Asked Questions

Can I track upload progress with fetch?

Not directly. fetch does not provide upload progress events. To track progress, use XMLHttpRequest with the upload.onprogress event, or check if your server supports streaming upload progress via WebSockets or server-sent events.

How large a file can I upload with fetch?

fetch itself has no file size limit, but servers, proxies, and browsers may impose limits. Typical server defaults are 1-50 MB. Always validate file size on the client before uploading to give the user immediate feedback.

Can I upload multiple files in one request?

Yes. Add each file to the same FormData instance using append(). The server receives them as an array of files in the multipart body.

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.