Beware implicit Content-Type: "application/json"

Published on in JavaScript, Node.js and TypeScript

If you are sending a FormData body with fetch, things might not work correctly if you are (accidentally) setting the Content-Type request header to "application/json".

Table of contents

Problem

I was sending a FormData object to a 3rd party API:

const formData = new FormData()
formData.append('foo', someValue)

const response = await request('https://example.com/api/', {
  body: formData,
  method: 'POST',
})

The API responses were confusing/incorrect.

Culprit

I was using request, which is a thin wrapper around fetch. Something like this:

async function request(url: string, options?: RequestInit) {
  const response = await fetch(url, {
    ...options,
    headers: {
      Accept: 'application/json',
      apiKey: 'abc123',
      'Content-Type': 'application/json',
      ...options.headers,
    },
  })

  if (response.ok) {
    return response.json()
  }

  // Handle errors etc.
}

Notice how I'm defaulting the Content-Type header to "application/json".

This is handy because most of the time I'm sending JSON.

But now I was sending a FormData object, so the implicit "application/json" value caused problems.

It took me a while to realize that this was the culprit for the confusing API responses.

Fix attempt 1

First I tried to "clear" the default Content-Type value:

const response = await request('https://example.com/api/', {
  body: formData,
  headers: {
    'Content-Type': undefined,
  },
  method: 'POST',
})

But whoops, it caused a TypeScript error (header values must be strings), and the value was set to the string "undefined". Not good.

Fix attempt 2

Then I tried to set the value to "multipart/form-data":

const response = await request('https://example.com/api/', {
  body: formData,
  headers: {
    'Content-Type': 'multipart/form-data',
  },
  method: 'POST',
})

But this didn't work either.

Explanation

This snippet makes it clear why the 2nd fix attempt didn't work:

const formData = new FormData()

const req = new Request('https://example.com/api/', {
  body: formData,
  method: 'POST',
})

console.log(...req.headers)
//=> ['content-type', 'multipart/form-data; boundary=---------------------------26864524337159351664014536661']

So my Content-Type header value ("multipart/form-data") was missing a boundary value, which is automatically generated by the browser (or Node.js or whatever) and actually required.

From Content-Type on MDN:

For multipart entities, the boundary parameter is required. It is used to demarcate the boundaries of the multiple parts of the message. [...]

[A multipart/form-data] request looks something like the following example with some headers omitted for brevity. In the request, a boundary of ExampleBoundaryString is used for illustration, but in practice, a browser would create a string more like this ---------------------------1003363413119651595289485765.

POST /foo HTTP/1.1
Content-Length: 68137
Content-Type: multipart/form-data; boundary=ExampleBoundaryString

--ExampleBoundaryString
Content-Disposition: form-data; name="description"

Description input value
--ExampleBoundaryString
Content-Disposition: form-data; name="myFile"; filename="foo.txt"
Content-Type: text/plain

[content of the file foo.txt chosen by the user]
--ExampleBoundaryString--

Final fix

The implicit Content-Type: "application/json" was the root cause, so I got rid of it. Well, at least partially:

async function request(url: string, options?: RequestInit) {
  const response = await fetch(url, {
    ...options,
    headers: {
      Accept: 'application/json',
      apiKey: 'abc123',
      ...(!(options.body instanceof FormData) && {
        'Content-Type': 'application/json',
      }),
      ...options.headers,
    },
  })

  // ...
}

Now if the body is a FormData object, fetch automatically sets the Content-Type header to multipart/form-data with an automatically generated boundary value.

Lessons

My first thought was that while this hand-rolled wrapper around fetch has been handy, maybe it would have been better to use a battle-tested library from the beginning. For example, Ky ("a tiny and elegant HTTP client based on the Fetch API") seems nice.

Or, at least, one should be careful about default/implicit values.

I wonder if the 3rd party API server should also be validating the request headers. The API endpoint expects a FormData body, so maybe it should also check that the Content-Type request header is valid. It would be a small thing to do, but it would have saved me a great amount of time.