skip to content

A file upload sent with fetch() and a FormData body starts failing on the server as soon as a developer adds headers: { 'Content-Type': 'multipart/form-data' } to the init object. Why does setting that header break the upload?

level: middleimportance: should knowfreq 42%

answer

  1. the body type decides the header
  2. an explicit header always wins
  3. multipart needs a random separator
  4. the server cannot guess the token
  5. JSON strings default to text/plain

basics

~20 s

A multipart body is split by a random boundary token the browser generates and normally advertises in the Content-Type header it sets for you. A hand-written multipart/form-data header has no boundary parameter, so the server cannot parse the parts. Omit the header.

solid answer

~50 s

When the `body` of a `fetch()` request is a `FormData`, the browser serialises it as `multipart/form-data` and generates a random boundary token that separates the parts. It puts that same token in the header it sets automatically: `Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryABC123`. An explicitly supplied header always wins over the derived one, so writing `Content-Type: multipart/form-data` by hand replaces a correct header with one that has no `boundary` parameter. The server now has no delimiter to split on and typically reports no fields, or answers 400. The fix is to set no `Content-Type` at all and let fetch derive it. The general rule: fetch infers a `Content-Type` from the body type — `URLSearchParams` becomes form-urlencoded, a `Blob` uses its own `type`, a plain string becomes `text/plain` — so a JSON string is the one common case where you *must* set the header yourself.

code

javascript · 13 lines
javascript
const form = new FormData();
form.append('title', 'Holiday photo');
form.append('file', fileInput.files[0]);

// No Content-Type header: fetch sets multipart/form-data plus the boundary.
await fetch('/api/uploads', { method: 'POST', body: form });

// A string body needs the header, because text/plain is the default.
await fetch('/api/items', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ title: 'Holiday photo' })
});

go deeper

for a junior

Remember the practical rule: with a FormData body, set no Content-Type and let fetch do it; with a JSON.stringify body, set application/json yourself.

for a middle

Explain that fetch derives a Content-Type from the body type but an explicit header wins, and describe what the multipart boundary token does in the serialised body and why only the sender knows it.

for a senior

Diagnose it from the request headers rather than the backend logs, and recognise the misleading symptom where the server answers 200 with empty fields. Know which body types imply which header and where an explicit one is genuinely required.

for a principal

Make it unrepeatable: a single request helper that owns body serialisation and header derivation, so no caller hand-writes a Content-Type, plus server-side validation that rejects a multipart request whose declared type carries no boundary instead of silently parsing zero fields.

## fetch derives Content-Type from the body The `body` you pass in the init object is not opaque bytes to the browser. The Fetch Standard defines, for each accepted body type, both how it is serialised and what `Content-Type` that serialisation implies: | body value | Content-Type the browser sets | | --- | --- | | `FormData` | `multipart/form-data; boundary=…` | | `URLSearchParams` | `application/x-www-form-urlencoded;charset=UTF-8` | | a string | `text/plain;charset=UTF-8` | | `Blob` / `File` | the blob's own `type` property, or nothing if it is empty | | `ArrayBuffer`, a typed array, `DataView` | nothing — no Content-Type is set | The derived value is only a default. If your `headers` already contain `Content-Type`, the browser uses yours and does not override it. That precedence is what makes the FormData case a trap rather than a harmless redundancy. ## Why the boundary matters A `multipart/form-data` body is a flat byte stream containing several parts, one per form field, each with its own small header block. The parts are separated by a delimiter line built from a token chosen by the sender: ``` ------WebKitFormBoundaryX3nQ\r\n Content-Disposition: form-data; name="title"\r\n\r\n Holiday photo\r\n ------WebKitFormBoundaryX3nQ\r\n Content-Disposition: form-data; name="file"; filename="a.png"\r\n Content-Type: image/png\r\n\r\n …binary bytes…\r\n ------WebKitFormBoundaryX3nQ--\r\n ``` The token is random precisely so it cannot collide with the payload bytes. The receiver has no way to guess it, which is why it must be declared in the `Content-Type` header as the `boundary` parameter. Header and body are two halves of one contract, and the browser is the only party that knows both. Write `Content-Type: multipart/form-data` by hand and you break that contract: the body still contains a boundary the browser chose, but the header no longer names it. The server-side multipart parser has nothing to split on. What you observe depends on the stack — a 400, a 500 from the parser, or, most confusingly, a 200 with every field empty and no file received, because the parser found zero parts and reported success. ## The mirror-image mistake: JSON The same precedence rule cuts the other way for JSON. `JSON.stringify(payload)` is a string, so the derived header is `text/plain;charset=UTF-8`. Many servers reject that with 415 Unsupported Media Type, or a body-parsing middleware simply declines to parse it and the handler sees an empty object. Here you *must* be explicit: ```js await fetch('/api/items', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Holiday photo' }) }); ``` So the rule is not "never set Content-Type" — it is "set it when the browser cannot know, and leave it alone when the browser knows better than you do". FormData and Blob bodies fall in the second group; strings fall in the first. This choice also affects whether the browser must send a CORS preflight before a cross-origin request, since only a small set of content types is exempt. ## Diagnosing it Open the request in devtools and look at the request headers. A healthy FormData upload shows a `Content-Type` ending in a long random `boundary=` token; a broken one shows a bare `multipart/form-data`. That single difference is the whole bug, and it is visible in one glance. The symptom also survives across layers in a way that misleads people: because the request *does* reach the server and the server *does* answer, teams often blame the backend parser or the file size limit. The header is where to look first. ## FormData is also how you read one back The symmetry is worth knowing: `response.formData()` parses a multipart or form-urlencoded *response* body into a `FormData`, using the boundary from that response's `Content-Type`. Same contract, opposite direction.

  • For which body types must you set Content-Type yourself, and why?
    For a plain string, because the derived default is `text/plain;charset=UTF-8` — so JSON payloads need an explicit `application/json`. For an `ArrayBuffer` or typed array, because no type is derived at all. `FormData`, `URLSearchParams` and `Blob` all carry enough information for the browser to derive a correct header, so leave those alone.
  • What does the server actually observe when the boundary parameter is missing?
    Its multipart parser has no delimiter, so it finds zero parts. Depending on the framework that surfaces as a 400 or a parser exception, or — most confusingly — a 200 where every field is empty and no file arrived. Teams chase a backend bug because the request reached the server and got a response.
  • How would you confirm this diagnosis in thirty seconds?
    Open the request in devtools and read its request headers. A working `FormData` upload shows `Content-Type: multipart/form-data; boundary=` followed by a long random token. A broken one shows the bare media type with no `boundary`. That single missing parameter is the whole bug.

saying these in an interview costs you the question

  • Sets multipart/form-data manually to be explicit
  • Thinks fetch sends no Content-Type unless you add one
  • Believes the server can infer the multipart boundary
  • Sends JSON.stringify output with no Content-Type header
  • Blames the backend parser without reading the request headers

context