Learning Objectives
- Write the same three fields as
application/x-www-form-urlencoded,multipart/form-data, andapplication/json, and read each off the wire - Choose between
URLSearchParams,FormData, andJSON.stringifyfor a given body - Say which controls
new FormData(form)collects and which it skips - Say what breaks when you set
Content-Typeon aFormDatabody, and what status the server returns - Name the
Content-Typefetchwrites for each kind of body value, and the one case where you have to write it yourself - List what
JSON.stringifydrops, converts, and throws on - Say why a
charsetparameter onapplication/jsonmeans nothing - Cut a payload down to what the endpoint reads
Three encodings
A browser can serialize a body three ways with no help from you. The enctype attribute on a <form> picks between application/x-www-form-urlencoded, which is the default, multipart/form-data, and text/plain. Step 01 used the first two. text/plain writes name=value one pair per line, and the HTML standard says its payloads are meant to be human readable and are not reliably interpretable by computer, because a newline inside a value looks like the newline that ends it. Do not send it to a real endpoint.
JSON is not on that list. No enctype value produces it, so a JSON body only exists because JavaScript built one.
Three fields run through this whole step. The title carries an accented character and two CJK characters.
application/x-www-form-urlencoded
One line. Pairs joined by &, name and value joined by =, and spaces written as +. Four punctuation characters survive, *, -, ., and _, along with the ASCII letters and digits. Everything else is percent-escaped one UTF-8 byte at a time. é is two bytes and becomes %C3%A9. 東 is three and becomes %E6%9D%B1.
URLSearchParams builds it. It applies the same serializer the form does, so a space comes out +, not %20. encodeURIComponent('a b') gives you a%20b.
It cannot carry a file. Every value is a string. To nest anything you and the server agree on a convention; the format has none.
multipart/form-data
A random string called the boundary, chosen per request, separates the fields. Each part gets a Content-Disposition: form-data header naming it, a blank line, then the value as raw bytes with no escaping at all. The last boundary carries a trailing --. Chrome's boundary is ----WebKitFormBoundary followed by sixteen random characters.
Those three fields cost 68 bytes urlencoded and 352 multipart, both measured through /api/echo in Chrome 151. The multipart figure moves between browsers, because the boundary token is theirs to choose and Chrome's is 38 characters long. The overhead is fixed per field, so it stops mattering as the values get long and matters a great deal when they are short. Files are what the format is for: a part can carry a filename and its own Content-Type, and the bytes go through unescaped.
application/json
Nested objects, arrays, numbers, booleans, and null, all typed. Both other formats have exactly one type: string.
JSON came out smallest here, at 66 bytes, because the values are short and the field names are the only overhead. Add a 2 MB image and the ranking inverts: JSON has no binary type, so the image has to be base64'd into a string, and base64 costs a third more bytes than the file it encodes. Multipart sends the same file as bytes.
| Encoding | Built with | Files | Types | Bytes for the three fields above, Chrome 151 |
|---|---|---|---|---|
application/x-www-form-urlencoded | URLSearchParams | No | Strings only | 68 |
multipart/form-data | FormData | Yes, as bytes | Strings and files | 352 |
application/json | JSON.stringify | Only base64'd into a string | Objects, arrays, numbers, booleans, null | 66 |
FormData from a form
new FormData(formElement) reads the form and collects its successful controls, the same set the browser would submit on its own.
| Control | Collected | Entry |
|---|---|---|
Text input, select, or textarea with a name | Yes | Its current value |
Any control with no name | No | |
disabled, or inside a disabled <fieldset> | No | |
| Unchecked checkbox or radio | No | |
Checked checkbox with no value attribute | Yes | The string "on" |
<select multiple>, two options selected | Yes | Two entries under one name |
Two controls sharing a name | Yes | Two entries, in document order |
| Submit button | No | Its name and value, but only when it is passed as the second argument |
<input type="image">, which is also a submit button | No | Passed as the second argument it adds two entries, name.x and name.y, both 0 outside a real click |
| Reset button, or any element that is not a submit button | Never | Passing one as the second argument throws TypeError |
<input type="file"> with nothing chosen | Yes | An empty File: name "", size 0, type application/octet-stream |
<input type="file" multiple>, two files chosen | Yes | Two File entries under one name |
The empty File on an untouched file input catches people. The entry is there, the name is right, and the value is a zero-byte file. A server that checks whether the field exists will conclude the user uploaded something. Check the size.
Buttons are the other surprise. A submit button is a successful control only when it is the button that submitted, and the one-argument constructor has no submitter to compare against, so it drops every button in the form. Pass the button as a second argument when you want it.
That argument takes a submit button and nothing else. Hand it a reset button, a type="button" button, or any other element and the constructor throws TypeError: Failed to construct 'FormData': The specified element is not a submit button. null is allowed and means the same as leaving the argument off, which matters because event.submitter is null when a form submits without a button.
The second argument arrived in Firefox 111 on 14 March 2023, Safari 16.4 on 27 March 2023, and Chrome 112 on 4 April 2023. Older engines ignore it rather than throwing, so the button quietly stays out of the body.
Do not set Content-Type yourself
Hand a FormData to fetch as body and it writes the header for you, including the boundary it generated for that particular body.
Write the header yourself and you have no way to know the boundary, because it is generated with the body.
The request leaves. The bytes arrive intact. Then the server tries to parse them and finds no boundary to split on. Against /api/posts, which calls request.formData(), the measured result is a 500:
The same request with the header left alone returns 201. Both buttons are in the demo below.
Content-Type is a parsing instruction
A body is bytes. Content-Type is how the server decides which parser to run on them.
fetch writes the header for you based on what you passed as body, and gets out of the way if you wrote one yourself. Measured in Chrome 151:
body | Content-Type fetch writes | bodyKind from /api/echo |
|---|---|---|
FormData | multipart/form-data; boundary=… | multipart |
URLSearchParams | application/x-www-form-urlencoded;charset=UTF-8 | urlencoded |
| A string | text/plain;charset=UTF-8 | other |
Blob with a type | That type | Whatever the type says |
Blob with no type | None | other |
ArrayBuffer or a typed array | None | other |
ReadableStream | None, and duplex: 'half' is required | other |
| Any of the above with a header you set | Yours, unchanged | Whatever you claimed |
Two of those rows are traps. The string row is the common one: JSON.stringify(fields) is a string, so a fetch with no headers option sends valid JSON labeled text/plain, and /api/echo reports bodyKind: "other". Many servers reject that, and the ones that sniff the body instead are doing you no favors. The last row is the one that produced the 500 above. Set the header only when you built the bytes yourself.
/api/echo is the instrument for this step
It reflects the request back at you: method, path, query, every header, the raw body as text, and a bodyKind it computes from Content-Type alone. json, urlencoded, multipart, other, or none for a GET or HEAD. It never parses the body, so it will call a body multipart on the strength of a header with no boundary in it.
JSON's limits
JSON.stringify takes a JavaScript value and gives back a JSON string, and JSON has six types: object, array, string, number, boolean, and null. Everything else converts, disappears, or throws.
| Value | As an object property | As an array element |
|---|---|---|
Date | ISO 8601 string | ISO 8601 string |
undefined | Key omitted | null |
| Function | Key omitted | null |
Symbol | Key omitted | null |
Infinity, -Infinity, NaN | null | null |
Map, Set | {}, contents gone | {}, contents gone |
BigInt | Throws TypeError | Throws TypeError |
| An object referring to itself | Throws TypeError | Throws TypeError |
The two throws announce themselves. V8's messages are Do not know how to serialize a BigInt and Converting circular structure to JSON. The rest are silent. The request succeeds, the server stores what it got, and the field is missing.
Date goes out as a string and comes back one
Date.prototype.toJSON calls toISOString, so JSON.stringify(new Date(0)) is "1970-01-01T00:00:00.000Z", in UTC, quotes included. JSON has no date type, so nothing on the way back turns it into one.
Agree on the string format with whoever runs the server, and call new Date(back.postedAt) on the way in. Pick ISO 8601 with an explicit offset.
Getting the others across
A Map becomes [...map], an array of pairs. A Set becomes [...set]. A BigInt becomes a string, and the server has to know it is one. Cycles need a real graph format, or a redesign of what you are sending. JSON.stringify takes a replacer function as its second argument if you want the conversion in one place.
Text encoding
Assume UTF-8 on every transmission, in both directions. Do not override it: JSON is UTF-8 by definition, and RFC 8259 states that no charset parameter is defined for application/json and that adding one has no effect on a compliant recipient. Check that the bytes you send are encoded the way you say. The file, the database column, and the header have to agree. Online the details matter: a title that renders as Café on somebody else's screen is UTF-8 bytes decoded with a single-byte table.
What is worth sending
Every byte in the body is paid for by somebody. On a slow connection it is paid in seconds.
Three fields cost 68 bytes urlencoded in the demo below. Turn on six more that /api/posts never reads and the same request grows by about 300 bytes, most of it a user agent string the server already has in a header. The endpoint validates title and author.
Don't send the whole object because you have it. A POST that updates a display name does not need the user's entire profile attached to it.
Don't send fields the server ignores. They cost bytes on every request, they end up in access logs, and the next person to read the code cannot tell which fields matter. A form with 40 fields where the endpoint reads 3 is a bug.
Don't send personal data you did not need to collect. A session id the server already has in a cookie, a user agent string it already has in a header, a precise location for a feature that wanted a city. Each one is now in a log, in a backup, and in whatever the logs get shipped to.
The reverse mistake exists too. Splitting one submit into four requests to keep each one small buys you four round trips, four chances to fail halfway, and four things to undo. Send what the endpoint reads, in one request.
Demo: one form, three encodings
The same fields, serialized three ways, POSTed to /api/echo, with the reflected bodyKind, Content-Type, byte count, and raw body in three panels side by side. The byte count is the Content-Length the server read off the request.
What changed from step 05: the transport selector is gone, because everything here runs on fetch. The outcome panel is replaced by the three payload panels. The instrumentation strip keeps the load counter, the request counter, the build time, and the query string, and adds the encoding, the bodyKind, and the byte count of the last body sent.
Five things to run.
- Read the same data three ways. Press Send all three. In Chrome the panels fill with 68, 352, and 66 bytes; the multipart figure depends on how long that browser's boundary token is. The title is percent-escaped in one, raw UTF-8 in the second, and inside a quoted JSON string in the third.
- Watch a field not get sent. Type into Your work in progress and send again. It is inside the form and it has no
name, so it appears in none of the three bodies. - Pay for fields nobody reads. Tick the box that enables the six extra fields. Every count jumps, by an amount that depends on your own user agent string, which is one of the six. Untick it and they drop back, because a disabled
<fieldset>disables everything inside it andFormDataskips disabled controls. - Break the boundary. Press POST /api/posts, header left alone for a 201. Press POST /api/posts, header set by hand for the 500 and the parser's complaint. The two bodies are the same size and the two requests differ by one header.
- Count the entries. Press one of the three buttons on its own. The panel reports what
new FormData(form)collected and what it would have collected with that button passed as the submitter, which is one more, and it names the pair. Press Send all three and the line says there is no submitter, because that control is atype="button"and the form was never submitted.
The waterfall records all three POSTs to /api/echo as three rows, all 200. The demo builds its JSON body from the same FormData the other two use rather than from Object.fromEntries, which keeps only the last value when two controls share a name.
Next Steps
Every request in the demo above returned 200 or 201, except the one engineered to fail. Step 7: When it goes wrong is about the rest of the time:
- What actually fails, from DNS to a 200 carrying a body that will not parse
- Picking a timeout and defending the number
- Which failures are worth retrying, and why a retried POST can create two of something
- Responses arriving out of order, and the two ways to stop a stale one from painting
- Treating a response body as untrusted input, including one from your own server