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 AbortController cancellation.
  • You do not need a precise upload progress bar — fetch cannot report upload progress (see below).

Prerequisites

  1. A File/Blob from an <input>, a drag-and-drop drop zone, or a Blob slice.
  2. A backend that parses multipart/form-data.
  3. 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 receive blob as the name.
  • Passing form as body makes the browser emit Content-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 DOMException whose name is "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.

fetch plus FormData upload flow and the progress gap FormData is posted with fetch; the browser adds the multipart boundary, but no upload progress events are available, unlike XHR. fetch + FormData upload FormData append(file) browser adds boundary server parses parts fetch no upload progress + AbortController XMLHttpRequest upload.onprogress use for a bar Choose fetch for simplicity; fall back to XHR only when you need a byte-accurate progress bar.
fetch posts FormData with an auto-generated boundary; for upload progress you still reach for XMLHttpRequest.

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.