Content-Type and MIME Types — What That Header Actually Controls (and Why charset Breaks JSON Parsers)
You probably set Content-Type by habit. Here's what actually happens when you get it wrong: silent body drops, broken JSON parsers, upload failures. A practical breakdown of MIME types, charset, boundaries, and how to stop guessing.
You set Content-Type: application/json and your server eats the request body. You send a file upload and the backend yells about a missing boundary. You add charset=utf-8 to your JSON endpoint and your parser chokes. These aren’t random bugs — they’re predictable consequences of a header that most of us half-understand.
The Content-Type header tells the receiver two things: what kind of data this is (the MIME type) and how to decode it (via parameters like charset or boundary). Get either part wrong and the receiver either rejects the body outright or silently misparses it. Neither is fun to debug at 11pm.
The structure of a Content-Type header
The full syntax is:
Content-Type: type/subtype; parameter=value
The type and subtype form the MIME type (application/json, text/html, image/png). The parameter is optional metadata that modifies how the content should be handled. The two parameters you’ll actually encounter are charset and boundary, and they behave very differently depending on the MIME type.
text/html vs application/json — they’re not interchangeable
Both of these carry text-based content. The difference is who reads them and how.
text/html tells the browser: render this as a document. It triggers full HTML parsing, layout, and rendering. Serve application/json where the browser expects text/html and you’ll see raw JSON in the viewport instead of a rendered page.
application/json tells the receiver: parse this as a JSON object. No rendering, no special encoding — just deserialize the bytes. The application/ prefix means the data is meant for an application to process, not a human to read directly.
Where this matters most: CORS preflight requests. Browsers treat application/json as a non-simple content type and fire an OPTIONS preflight before the actual request. text/plain doesn’t trigger preflight. This is why some legacy APIs accept JSON but advertise text/plain — it sidesteps preflight at the cost of correctness. Don’t do this in new code.
Why charset breaks JSON parsers
This one comes up constantly. You see an API with:
Content-Type: application/json; charset=utf-8
Looks reasonable. UTF-8 JSON is fine, right? The problem is that RFC 7159 (and its successor RFC 8259) explicitly state that JSON MUST be encoded in UTF-8 and that the charset parameter has no effect on JSON processing. A conformant JSON parser reads the bytes and decodes them as UTF-8 regardless of what charset says.
The real breakage happens when strict parsers enforce RFC 9110 and reject content types they don’t recognize as valid. Some frameworks tokenize the full content type string and fail if they get application/json; charset=utf-8 instead of a bare application/json. Spring’s default MappingJackson2HttpMessageConverter accepts both, but stricter validators in some Go and Rust HTTP libraries will match only the exact media type — add charset=utf-8 and your request returns a 415 Unsupported Media Type.
The fix: send Content-Type: application/json for JSON endpoints. No charset. If you need to be explicit about encoding, document it — JSON is always UTF-8 by spec.
multipart/form-data and the boundary you can’t skip
File uploads break in a specific way that’s immediately recognizable once you know what to look for. The request arrives, the server parses it, and the file array is empty. No error. Just nothing.
The reason: multipart/form-data is different from every other content type because the body contains multiple parts, each with its own headers, separated by a delimiter. That delimiter is the boundary parameter:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
Without the boundary, the server has no way to split the body into parts. It can’t find where one field ends and the next begins. The entire body is treated as unparseable noise.
The most common way to trigger this bug: manually setting Content-Type: multipart/form-data in your fetch/axios request. As soon as you set it manually, the browser (or HTTP client) no longer generates the boundary automatically. The header exists, the body is multipart, but the boundary is missing from the header — and the server silently drops all fields.
The fix: For file uploads using FormData, don’t set Content-Type at all. Let the browser or HTTP client set it. They’ll generate a correct boundary and wire it into the header automatically.
// WRONG — kills the auto-generated boundary
fetch('/upload', {
method: 'POST',
headers: { 'Content-Type': 'multipart/form-data' }, // ← don't do this
body: formData,
});
// CORRECT — let the browser handle Content-Type
fetch('/upload', {
method: 'POST',
body: formData, // browser sets Content-Type + boundary automatically
});
application/x-www-form-urlencoded — the other form encoding
application/x-www-form-urlencoded is what HTML forms send by default when there’s no file input. The body looks like a query string:
name=Alice&age=30&city=New+York
Keys and values are percent-encoded (spaces become + or %20). No parts, no boundaries — just a flat key-value string.
Two places where developers mix this up with JSON:
- Sending JSON as a form body — setting
Content-Type: application/x-www-form-urlencodedbut sending a JSON string in the body. The server’s form parser reads it as a malformed key-value string and silently produces garbage or an empty object. - Sending form data to a JSON endpoint — the opposite. The server expects
application/json, gets a form-encoded body, and returns 400 or silently ignores the body entirely.
If you’re building a curl command to hit a form endpoint, the cURL Command Builder can generate the right flags and content type headers automatically — useful for verifying what your frontend is actually sending.
Common MIME types — what they signal and what breaks when you get them wrong
| MIME Type | Correct use case | What breaks when misused |
|---|---|---|
application/json | REST API request/response bodies | Adding charset=utf-8 causes 415 errors on strict parsers; sending form data under this type produces empty request bodies |
text/html | Browser-rendered HTML documents | Serving JSON as text/html renders raw text; serving HTML as application/octet-stream triggers a download instead of rendering |
multipart/form-data | File uploads, forms with binary data | Missing boundary parameter makes the body unparseable — server sees empty fields regardless of what the body contains |
application/x-www-form-urlencoded | HTML form submissions (text fields, no files) | Sending JSON in this content type produces malformed key-value pairs; binary data gets corrupted by percent-encoding |
application/octet-stream | Unknown binary data, generic file downloads | Forces download in browser; HTTP clients that parse response bodies may fail to deserialize a meaningful response |
text/plain | Plain text, simple log output | Using as a workaround to avoid CORS preflight is a footgun — proxies and CDNs may interpret and transform content differently |
application/xml / text/xml | XML data exchange | text/xml defaults to US-ASCII charset (per RFC); application/xml defaults to UTF-8 — mixing them causes encoding errors on non-ASCII characters |
image/jpeg, image/png, image/webp | Raster image responses | Wrong subtype causes some browsers to refuse rendering; serving image/jpeg for a PNG can corrupt image parsers on strict clients |
application/pdf | PDF documents | Serving as application/octet-stream always triggers download; serving as text/plain renders PDF bytecode in the browser as garbage |
multipart/mixed | Email attachments, batch API responses | Boundary handling same as multipart/form-data — missing boundary parameter = no parsing. Less common in HTTP but used in Gmail API and some batch endpoints |
How proxies and CDNs use Content-Type
Content-Type isn’t just between your client and server. Reverse proxies and CDNs read it too, and they make caching and transformation decisions based on it.
Cloudflare, for example, will only apply Minify (JS/CSS/HTML compression) to responses that carry the correct content types. Serve JavaScript as application/octet-stream and it won’t be minified. More importantly, some CDNs vary their cache behavior: HTML is cached with different TTLs than JSON, and application/json responses may bypass certain cache layers entirely depending on configuration.
Nginx’s gzip_types directive uses MIME types to decide what to compress. If your API responses carry application/json and you haven’t listed it in gzip_types, they’re not getting compressed — even if gzip is enabled globally. The default gzip_types in most distro configs only includes text/html.
Sniffing: when browsers ignore Content-Type
If you serve a response without a Content-Type header (or with an obviously wrong one), browsers don’t just give up — they sniff the content. They look at the first few bytes and guess: is this an HTML document? A script? An image?
This is called MIME sniffing and it’s a security hole. A file upload endpoint that doesn’t validate file type can be exploited by serving an HTML file with an image MIME type — the browser might sniff it as HTML, execute it, and you have an XSS vector. The X-Content-Type-Options: nosniff response header tells browsers to trust the declared type and refuse to sniff. Set it on all responses you control.
You can inspect what headers your server is actually sending with the HTTP Header Analyzer — paste a response or URL and see exactly what Content-Type and X-Content-Type-Options values are being returned.
Debugging Content-Type mismatches in practice
When something breaks and you suspect Content-Type, the fastest way to confirm is to check both sides of the exchange: what your client is sending and what your server is seeing.
On the client side: browser DevTools → Network tab → select the request → Headers panel. Look at the Request Headers section, not Response Headers. You want to see the actual outgoing Content-Type, not what you think you set in code.
On the server side (Node/Express): req.headers['content-type']. Flask: request.content_type. Django: request.content_type. Log it on the first line of your handler before anything else touches the body.
When you need to test quickly with curl, the cURL Command Builder saves time — you can set the method, headers, and body format and get the exact command to paste into your terminal.
Content-Type is one of those headers that feels like ceremony until it breaks something. The short version: bare application/json for JSON, no charset; never manually set multipart/form-data when using FormData; match your content type to what the receiver actually expects. When in doubt, check the actual outgoing headers in DevTools — the gap between what you think you’re sending and what’s actually on the wire is usually where the bug lives.
You may also like
Install Our Extensions
Add IO tools to your favorite browser for instant access and faster searching
恵 Scoreboard Has Arrived!
Scoreboard is a fun way to keep track of your games, all data is stored in your browser. More features are coming soon!
Must-Try Tools
View All New Arrivals
View AllUpdate: Our latest tool was added on Jun 23, 2026
