Uploading Files with Fetch and FormData
To upload a file with fetch, build a FormData, append the File, and pass it as the request body — and crucially, do not set a Content-Type header yourself: the browser generates the correct multipart/form-data value with the boundary token automatically.
This article is part of the modern Fetch API for uploads topic inside upload fundamentals and browser APIs. It covers the request itself, cancellation, and fetch’s one real limitation.
When to use this approach
- You are uploading one or more files (plus metadata fields) to your own backend as a standard form post.
- You want a clean promise-based API and built-in
AbortControllercancellation. - You do not need a precise upload progress bar — fetch cannot report upload progress (see below).
Prerequisites
- A
File/Blobfrom an<input>, a drag-and-drop drop zone, or a Blob slice. - A backend that parses
multipart/form-data. - TypeScript with
lib: ["DOM", "ES2022"].
Implementation
The whole upload is a few lines. The subtlety is in what you must not do: setting Content-Type manually overwrites the auto-generated boundary and breaks server parsing.
export interface UploadOptions {
url: string;
file: File;
fields?: Record<string, string>;
timeoutMs?: number;
signal?: AbortSignal;
}
export async function uploadFile(opts: UploadOptions): Promise<Response> {
const { url, file, fields = {}, timeoutMs = 30_000, signal } = opts;
const form = new FormData();
form.append("file", file, file.name); // third arg sets the filename
for (const [key, value] of Object.entries(fields)) {
form.append(key, value);
}
// Combine a caller signal with an internal timeout signal.
const timeout = AbortSignal.timeout(timeoutMs);
const composite = signal ? AbortSignal.any([signal, timeout]) : timeout;
const response = await fetch(url, {
method: "POST",
body: form, // DO NOT set Content-Type — the browser adds the boundary.
signal: composite,
});
if (!response.ok) {
throw new Error(`upload failed: HTTP ${response.status} ${response.statusText}`);
}
return response;
}
Usage with manual cancellation:
const controller = new AbortController();
document.querySelector("#cancel")!.addEventListener("click", () => controller.abort());
const input = document.querySelector<HTMLInputElement>("#file")!;
input.addEventListener("change", async () => {
const file = input.files?.[0];
if (!file) return;
try {
const res = await uploadFile({
url: "/api/upload",
file,
fields: { album: "vacation" },
signal: controller.signal,
});
console.log("done:", await res.json());
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
console.warn("upload cancelled or timed out");
} else {
throw err;
}
}
});
Line-by-line on the critical parts
form.append("file", file, file.name)— the third argument sets the part’s filename. Without it some servers receiveblobas the name.- Passing
formasbodymakes the browser emitContent-Type: multipart/form-data; boundary=----WebKitFormBoundary.... Setting that header yourself omits the boundary the server needs, producing an empty or unparseable body. AbortSignal.timeout(timeoutMs)aborts the request after the deadline;AbortSignal.any([...])merges it with the caller’s manual signal so either source cancels.- An aborted fetch rejects with a
DOMExceptionwhosenameis"AbortError"— distinguish it from real failures to drive retry vs cancel logic, which pairs with the browser timeout and retry logic guide.
The body sent is identical in structure to a hand-built multipart request — see multipart form data explained for the wire format the browser is generating for you.
The progress limitation
fetch cannot report upload progress. The Streams-based request body that would enable it is not broadly available, so there is no onprogress for bytes sent. The diagram contrasts the two options.
Configuration gotchas
Manually setting Content-Type breaks the upload. Writing headers: { "Content-Type": "multipart/form-data" } omits the boundary, so the server reads an empty body — often surfacing as 400 Bad Request or empty req.files. Never set it; let the browser do it.
Forgetting the filename argument. form.append("file", file) without the third argument can send blob as the filename, breaking extension-based handling. Pass file.name.
AbortError mistaken for a network failure. An aborted or timed-out fetch rejects with DOMException named "AbortError", not a TypeError. Check err.name so you don’t retry an intentional cancel.
No response.ok check. fetch only rejects on network errors, not on 4xx/5xx. A 500 resolves normally; you must inspect response.ok and throw yourself.
Testing / verification
Confirm the request shape with curl, then assert client-side:
curl -i -X POST http://localhost:3000/api/upload \
-F "file=@./sample.jpg" \
-F "album=vacation"
# Expect 200 and a JSON body echoing the stored filename.
const res = await uploadFile({ url: "/api/upload", file });
console.assert(res.ok, "expected 2xx response");
const ct = res.headers.get("content-type") ?? "";
console.assert(ct.includes("application/json"), "expected JSON reply");
console.log("upload verified");
FAQ
Why is my multipart upload empty on the server?
You almost certainly set Content-Type manually. The boundary token is generated only when you let the browser set the header from a FormData body. Remove the header entirely.
Can fetch report upload progress for a progress bar?
No. fetch has no upload onprogress, and streaming request bodies that would enable it are not broadly supported. Use XMLHttpRequest with xhr.upload.onprogress when you need a byte-accurate bar.
How do I cancel or time out a fetch upload?
Pass an AbortSignal. Use AbortSignal.timeout(ms) for a deadline and AbortController for a manual cancel button; merge them with AbortSignal.any([...]). An abort rejects with a DOMException named "AbortError".
Does a 500 response reject the fetch promise?
No. fetch only rejects on network-level failures. HTTP error statuses resolve normally, so always check response.ok and throw or branch yourself.