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

  1. A browser baseline of Chrome 105+, Firefox 115+ or Safari 16.4+ — AbortSignal.timeout() and AbortSignal.any() are used below without polyfills.
  2. A File or Blob, from an <input type="file">, a drag-and-drop drop zone, or a canvas/MediaRecorder output.
  3. 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.
  4. 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.

How a FormData entry list is serialised into a multipart body Two FormData entries, a File and a string, expand into delimiter lines, per-part headers, raw payload bytes and a closing epilogue. From entry list to bytes on the wire FormData entry list 1. "file" → File clip.mp4 · video/mp4 2. "album" → "vacation" string entry Insertion order is preserved. append() keeps duplicates, set() replaces the first match. Serialised request body ------WebKitFormBoundaryF6Kd2q9x delimiter (42 B) Content-Disposition: form-data; name="file"; filename="clip.mp4" part headers Content-Type: video/mp4 from file.type 12,582,912 raw bytes no re-encoding ------WebKitFormBoundaryF6Kd2q9x delimiter Content-Disposition: name="album" string part vacation no Content-Type ------WebKitFormBoundaryF6Kd2q9x-- epilogue (44 B) Every line ends CRLF. Fixed cost: 137 bytes per file part, 100 for a short string part, 44 to close.
The browser owns the boundary token, the per-part headers and the closing epilogue — you only own the entry list.

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 writes filename="…" into the part header. Omit it for a File and the browser still uses file.name; omit it for a plain Blob and the part is labelled filename="blob", which breaks any extension-based routing on the server.
  • form.set() for metadata, form.append() for filesset replaces every existing entry with that name, so re-running the loop cannot produce duplicate album fields. append preserves duplicates, which is exactly what you want when several files share one field name.
  • No Content-Type header — passing FormData as body makes the browser emit Content-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 a GET and 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 a DOMException named AbortError; 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 still JSON.parse. A body may only be consumed once per Response.

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.

Bytes on the wire for one 12 MiB clip under three encodings A raw PUT sends 12,582,912 bytes, a FormData POST adds 281 bytes of framing, and base64 inside JSON adds over four million bytes. Wire cost of one 12 MiB clip plus one metadata field PUT: raw file body POST: FormData POST: base64 in JSON 12,582,912 B 12,583,193 B (+281 B) 16,777,276 B (+33.3%) 0 4 MiB 8 MiB 12 MiB 16 MiB Multipart framing is a constant, so its share shrinks as files grow: 0.28% on a 100 KB file, 0.002% on this one.
Multipart adds a flat 281 bytes here; base64 adds 4.19 MB to the same upload.

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

Observable events during one 12 MiB POST XMLHttpRequest emits upload progress events throughout the body transfer while fetch offers only a single settlement point once response headers arrive. One 12 MiB POST: what each API lets you observe XMLHttpRequest request body streaming out response upload.onprogress fires roughly every 50 ms, with loaded / total fetch identical bytes, zero events response await fetch() settles only here 0 s 2 s 4 s 6 s 8 s
Both APIs put the same bytes on the wire; only XHR tells you how many have left.

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.