Uploading Files with Fetch and FormData
Build a FormData, append the File with its filename, pass the object straight to fetch as body — and never set Content-Type yourself, because the browser has to append the boundary token that separates the parts.
This article sits in the modern Fetch API for uploads topic inside upload fundamentals and browser APIs. It covers the FormData object itself: how its entry list becomes bytes, what those bytes cost, which RequestInit fields change the outcome, and the five ways this goes wrong in production.
When to use this approach
- You are posting one or more files plus metadata fields to your own endpoint, and you want a single request that a standard server parser understands without custom framing.
- You want the browser to own encoding: boundary generation, per-part headers,
Content-Length, and UTF-8 filename escaping are all handled for you. - You do not need a byte-accurate progress bar, and the payload comfortably fits your proxy’s body limit. Above roughly 100 MB, prefer chunking with Blob.slice or a direct-to-storage
PUT— see presigned POST vs presigned PUT for browser uploads for that split.
Prerequisites
- A browser baseline of Chrome 105+, Firefox 115+ or Safari 16.4+ —
AbortSignal.timeout()andAbortSignal.any()are used below without polyfills. - A
FileorBlob, from an<input type="file">, a drag-and-drop drop zone, or a canvas/MediaRecorderoutput. - A server that parses
multipart/form-data— busboy, multer, formidable, Django, Rails. The mechanics of the receiving side are in parsing multipart/form-data in a Node server. - TypeScript with
"lib": ["DOM", "DOM.Iterable", "ES2022"], or plain ESM JavaScript with the annotations stripped.
What FormData actually is
FormData is not a JSON-ish bag of properties. It is an ordered list of entries, each a name paired with either a string or a Blob. Order is insertion order, duplicate names are legal, and nothing is serialised until you hand the object to fetch. At that moment the browser runs the extract a body algorithm: it picks a boundary token, walks the entry list once, and produces the byte frame below.
Three details from that frame matter in practice. The boundary token is browser-specific: Chromium and WebKit emit ----WebKitFormBoundary plus 16 random alphanumerics, Firefox emits a run of dashes followed by digits, and Node’s undici emits ----formdata-undici- plus digits. Never hard-code it or assert on it in tests. Second, the part’s Content-Type is copied verbatim from blob.type, which the operating system guessed from the file extension — treat it as a hint, not evidence, as why browser MIME types are unreliable explains. Third, filenames go out as raw UTF-8 with only ", CR and LF percent-escaped; there is no RFC 2231 filename* parameter, so a server that decodes the header as Latin-1 will mangle résumé.pdf.
For the full wire grammar — nested parts, Content-Transfer-Encoding, the historical baggage — see multipart form data explained.
Implementation
One function, complete and copy-pasteable. It composes a caller signal with a deadline, refuses redirects, and classifies the response before anyone touches the body.
export interface UploadOptions {
url: string;
file: File;
fields?: Record<string, string>;
headers?: Record<string, string>;
timeoutMs?: number;
signal?: AbortSignal;
}
export interface UploadResult {
status: number;
body: unknown;
}
export class UploadError extends Error {
constructor(message: string, readonly status: number, readonly detail: string) {
super(message);
this.name = "UploadError";
}
}
export async function uploadFile(opts: UploadOptions): Promise<UploadResult> {
const { url, file, fields = {}, headers = {}, timeoutMs = 120_000, signal } = opts;
const form = new FormData();
// Metadata first: a streaming parser sees fields before it has to buffer or
// pipe the file, so it can reject a bad request without reading the payload.
for (const [key, value] of Object.entries(fields)) form.set(key, String(value));
form.append("file", file, file.name); // 3rd arg sets filename="…"
const timeout = AbortSignal.timeout(timeoutMs);
const composite = signal ? AbortSignal.any([signal, timeout]) : timeout;
const response = await fetch(url, {
method: "POST",
body: form, // do NOT add a Content-Type header here
headers, // auth/correlation headers only
credentials: "same-origin",
redirect: "error", // a 303 would turn this into a GET and drop the file
signal: composite,
});
const text = await response.text();
if (!response.ok) {
throw new UploadError(
`upload failed: HTTP ${response.status} ${response.statusText}`,
response.status,
text.slice(0, 512),
);
}
return { status: response.status, body: text.length > 0 ? JSON.parse(text) : null };
}
Wiring it to an input plus a cancel button:
const input = document.querySelector<HTMLInputElement>("#file")!;
const cancel = document.querySelector<HTMLButtonElement>("#cancel")!;
input.addEventListener("change", async () => {
const file = input.files?.[0];
if (!file) return;
const controller = new AbortController(); // one controller per attempt
const onCancel = () => controller.abort(new DOMException("user cancelled", "AbortError"));
cancel.addEventListener("click", onCancel, { once: true });
try {
const result = await uploadFile({
url: "/api/upload",
file,
fields: { album: "vacation", capturedAt: new Date().toISOString() },
signal: controller.signal,
});
console.log("stored:", result.body);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
console.warn("cancelled or timed out after 120 s");
} else if (err instanceof UploadError) {
console.error(`server rejected it (${err.status}):`, err.detail);
} else {
console.error("network failure:", err); // TypeError: Failed to fetch
}
} finally {
cancel.removeEventListener("click", onCancel);
}
});
Line-by-line on the parameters that matter
form.append("file", file, file.name)— the third argument writesfilename="…"into the part header. Omit it for aFileand the browser still usesfile.name; omit it for a plainBloband the part is labelledfilename="blob", which breaks any extension-based routing on the server.form.set()for metadata,form.append()for files —setreplaces every existing entry with that name, so re-running the loop cannot produce duplicatealbumfields.appendpreserves duplicates, which is exactly what you want when several files share one field name.- No
Content-Typeheader — passingFormDataasbodymakes the browser emitContent-Type: multipart/form-data; boundary=----WebKitFormBoundary…. Setting the header yourself replaces that value and drops the boundary, so the parser has nothing to split on. redirect: "error"— on a 307/308 the browser replays the entire body against the new location, doubling your egress; on a 301/302/303 it converts the request to aGETand discards the file, so the server sees an empty request and you see a confusing 405. Failing loudly is better than either.AbortSignal.any([signal, timeout])— merges the caller’s cancel button with a deadline. A signal that fires rejects the promise with aDOMExceptionnamedAbortError; scaling that deadline to the file size, and adding a stall clock instead of a total-duration clock, is covered in aborting uploads with AbortController and timeouts.await response.text()before branching — reading the body once, into a string, means the error path has the server’s diagnostic and the success path can stillJSON.parse. A body may only be consumed once perResponse.
Building FormData from an existing form
new FormData(formElement) snapshots every named, enabled control in the form, including all files selected in a multiple input. Unnamed and disabled controls are skipped silently, which is the usual reason a field goes missing.
const formEl = document.querySelector<HTMLFormElement>("#upload-form")!;
formEl.addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(formEl);
form.set("clientTs", new Date().toISOString());
const files = form.getAll("attachments").filter((v): v is File => v instanceof File);
// An untouched <input type="file" name="attachments"> still contributes one
// empty part: filename="" with zero bytes. Most validators choke on it.
if (files.length === 1 && files[0].size === 0 && files[0].name === "") {
form.delete("attachments");
}
const total = files.reduce((n, f) => n + f.size, 0);
if (total > 100 * 1024 * 1024) {
throw new Error(`selection is ${(total / 1048576).toFixed(1)} MB; endpoint caps at 100 MB`);
}
const response = await fetch(formEl.action, { method: "POST", body: form });
console.log(response.status, await response.json());
});
On the server that arrives as repeated parts under one name: upload.array("attachments", 10) in multer, or one file event per part in busboy.
Configuration reference
These are the RequestInit fields that change what actually leaves the browser for a FormData body.
| Option | Type | Default | Effect on a FormData upload |
|---|---|---|---|
method |
string | "GET" |
Must be POST or PUT. GET/HEAD with a body throws TypeError: Request with GET/HEAD method cannot have body. |
body |
FormData |
— | Serialised at request-construction time; the boundary is chosen then, not at send time. |
headers["Content-Type"] |
string | auto | Leave unset. Any value you supply overrides the generated one and loses the boundary. |
credentials |
omit | same-origin | include |
same-origin |
include is required for cookies cross-origin, and forces the server to echo Access-Control-Allow-Credentials: true. |
redirect |
follow | error | manual |
follow |
follow silently replays or drops the body on a 3xx. Prefer error for uploads. |
signal |
AbortSignal |
null |
Aborts the in-flight request; rejects with DOMException named AbortError. |
keepalive |
boolean | false |
Caps the total body at 64 KiB. Unusable for files; a 5 MB upload throws TypeError: Failed to fetch. |
cache |
string | "default" |
Irrelevant for POST, but "no-store" avoids a stale 304 on retry endpoints. |
duplex |
"half" |
— | Only meaningful for ReadableStream bodies, never for FormData. |
And the FormData methods you will reach for:
| Call | Behaviour |
|---|---|
append(name, value) |
Adds an entry; duplicates allowed. Non-string values are stringified ([object Object] for a plain object). |
append(name, blob, filename) |
Adds a file part with an explicit filename=. |
set(name, value) |
Removes all entries with that name, then appends one. |
getAll(name) |
Returns every value for the name, in order — the only safe accessor for repeated fields. |
delete(name) |
Drops every entry with that name. Safe to call on a name that does not exist. |
entries() |
Iterates [name, value] pairs; use it to log a payload before sending. |
What the multipart encoding costs
The overhead is fixed per part, not proportional to the payload — 137 bytes for a typical file part, about 100 for a short string field, 44 to close the body. That is the whole argument against base64: multipart carries your bytes verbatim, while base64 inside a JSON envelope inflates every request by a third before compression, as base64 vs binary encoding works through.
Because every entry’s size is known up front — Blob.size is synchronous — the browser can compute an exact Content-Length for a FormData body. That is what lets a reverse proxy reject an oversized upload from the request headers alone, before a single payload byte arrives, which is why a too-large upload usually fails in tens of milliseconds rather than after a minute of transfer. Tuning those thresholds is covered in raising Nginx and Cloudflare upload size limits.
Progress, and the honest limit of fetch
fetch exposes no upload progress event, and it never will in its current shape: the Response promise settles when the response headers arrive, and there is no observer between “request started” and “headers received”.
You have three honest options. Use XMLHttpRequest with xhr.upload.onprogress when the payload is a FormData and you need a real bar. Split the file and count completed chunks, which gives coarse but resumable progress. Or replace FormData with a stream body and measure it yourself — see tracking upload progress with a TransformStream, remembering that a ReadableStream body is Chromium-only and forces you to frame the multipart bytes by hand.
Configuration gotchas
Setting Content-Type yourself. headers: { "Content-Type": "multipart/form-data" } sends a header with no boundary parameter. busboy throws Error: Multipart: Boundary not found and Express returns 500; a Rails endpoint reports EOFError: bad content body. Delete the header. If you need auth, send Authorization and nothing else.
Field name mismatch. form.append("upload", file) against upload.single("file") in multer fails with MulterError: Unexpected field, HTTP 500, and an empty req.file. The append key and the multer field name are one contract — keep them in a shared constant.
Non-string values silently stringified. form.append("tags", ["a", "b"]) sends a,b; form.append("meta", { id: 1 }) sends the literal [object Object]; form.append("size", undefined) sends "undefined". Serialise deliberately with JSON.stringify, or append a new Blob([json], { type: "application/json" }) so the part carries a real content type.
A custom header turns a simple request into a preflighted one. A multipart/form-data POST is CORS-safelisted, so it goes out with no OPTIONS. Add X-Request-Id or Authorization and the browser sends a preflight first; if the server does not list that header in Access-Control-Allow-Headers you get Response to preflight request doesn't pass access control check and zero bytes are uploaded. Fix it at the origin — see fixing CORS preflight errors on S3 uploads.
Assuming fetch rejects on failure. A 413, 422 or 500 resolves normally; fetch only rejects for network-level faults, an abort, or a CORS violation. Check response.ok. Then be careful about retrying a POST that may have already succeeded — attach an idempotency key, as in retrying fetch uploads with idempotency keys.
Verification
First prove the wire shape locally, without a server. Save this as probe.mjs and run it with Node 20+; Request performs the same body extraction the browser does.
const file = new File(["PNGDATA"], "pixel.png", { type: "image/png" });
const form = new FormData();
form.append("album", "vacation");
form.append("file", file, file.name);
const probe = new Request("https://example.invalid/api/upload", { method: "POST", body: form });
const contentType = probe.headers.get("content-type") ?? "";
const raw = await probe.text();
console.log(contentType);
console.log(JSON.stringify(raw));
const boundary = contentType.split("boundary=")[1] ?? "";
console.assert(contentType.startsWith("multipart/form-data; boundary="), "no boundary generated");
console.assert(boundary.length > 8, "boundary token too short");
console.assert(raw.includes('filename="pixel.png"'), "filename not serialised");
console.assert(raw.includes("Content-Type: image/png"), "part content type missing");
console.assert(raw.trimEnd().endsWith(`--${boundary}--`), "closing epilogue missing");
console.log("framing overhead:", raw.length - file.size, "bytes");
Expected output starts multipart/form-data; boundary=----formdata-undici-0… and ends with a framing overhead in the low hundreds of bytes. Then hit the real endpoint and check the status line only:
curl -sS -o /dev/null -D - -X POST http://localhost:3000/api/upload \
-F "album=vacation" \
-F "file=@./clip.mp4;type=video/mp4"
Expect HTTP/1.1 201 Created. In DevTools, the Network panel’s Payload tab must show multipart/form-data; boundary=… with a token present — if the boundary is missing there, you set the header somewhere, and no amount of server-side debugging will help.
Frequently Asked Questions
Should I set Content-Length myself?
No — it is a forbidden header name, so the browser silently ignores your value and computes the real one from the serialised body. This is also why a FormData upload always arrives with a length while a stream body does not.
Can I reuse the same FormData instance for a retry?
Yes. FormData is re-serialised on every request construction, so passing the same object to a second fetch call works and produces a fresh boundary token. What you cannot reuse is a Request or a ReadableStream body — those are consumed once and the second attempt throws TypeError: Body is unusable.
Can fetch report upload progress yet?
Not for a FormData body, in any browser. The only in-fetch route is a ReadableStream request body with duplex: "half", which is Chromium-only, requires HTTP/2, and means writing the multipart framing yourself. For a plain progress bar, XMLHttpRequest remains the pragmatic answer.
How do I send several files under one field name?
Call form.append("attachments", file, file.name) once per file. The body carries one part per call, all with name="attachments", and the server reads them as a list — req.files with upload.array(), or repeated file events in busboy. Use append, never set, or each call would delete the previous one.
Does an HTTP 500 reject the fetch promise?
No. fetch resolves for every status the server actually returns, including 4xx and 5xx; it rejects only for DNS failures, TLS errors, dropped sockets, aborts, and blocked CORS responses. Branch on response.ok and throw your own typed error, as uploadFile does above.