skip to content

Your telemetry endpoint expects JSON, but requests sent with navigator.sendBeacon('/collect', JSON.stringify(payload)) arrive with Content-Type: text/plain;charset=UTF-8. Why does that happen, and what are your options?

level: middleimportance: nice to knowfreq 35%

answer

  1. no headers argument exists
  2. the body type sets the header
  3. strings become text/plain
  4. only three safelisted content types
  5. a typed Blob invites a preflight

basics

~20 s

sendBeacon accepts no headers, so the Content-Type is derived from the body type, and a string always becomes text/plain;charset=UTF-8. Either parse text/plain on the server, send a Blob typed application/json, or use fetch with keepalive and explicit headers.

solid answer

~50 s

`navigator.sendBeacon()` has no headers argument at all — the `Content-Type` is inferred from what you pass as the body, and a plain string is always sent as `text/plain;charset=UTF-8`. You have three ways out. The simplest is to let the endpoint accept `text/plain` and parse the raw body as JSON, which many collectors do deliberately. The second is to wrap the payload in a `Blob` with `type: 'application/json'`, which does set the header — but `application/json` is not a CORS-safelisted content type, so a cross-origin beacon then needs a preflight, and a preflight fired as the page is closing frequently does not complete. The third, and the one I would reach for when the endpoint is cross-origin or the header matters, is `fetch(url, { method: 'POST', body, headers: { 'Content-Type': 'application/json' }, keepalive: true })`, which gives the same survive-the-page guarantee with real headers.

code

javascript · 13 lines
javascript
const payload = { event: 'session_end', ms: 41230 };

// text/plain;charset=UTF-8 — safelisted, never preflighted
navigator.sendBeacon('/collect', JSON.stringify(payload));

// application/json — correct header, but preflighted cross-origin
navigator.sendBeacon(
  '/collect',
  new Blob([JSON.stringify(payload)], { type: 'application/json' })
);

// application/x-www-form-urlencoded — safelisted and structured
navigator.sendBeacon('/collect', new URLSearchParams({ event: 'session_end', ms: '41230' }));

go deeper

for a junior

Recall that sendBeacon cannot set headers and that a string body is labelled text/plain. Knowing that the server must be able to read that is enough at this level.

for a middle

Explain the mapping from body type to Content-Type, list the three CORS-safelisted content types, and say why a Blob typed application/json changes a cross-origin beacon into a preflighted request.

for a senior

Weigh the options against where the endpoint lives: same-origin makes the typed Blob safe, cross-origin makes a safelisted type or a keepalive fetch the sound choice, and you can explain the staging-versus-production failure pattern.

for a principal

Set the contract for the collector itself — accept a safelisted content type by design, keep exit sends to a single request with no preflight, and version the payload in the body rather than in a header that costs a round trip.

## Where the header comes from `navigator.sendBeacon(url, data)` takes exactly two arguments. There is no third options object and no way to attach headers. The browser therefore derives the `Content-Type` from the *type of the body value* you handed it, the same way the Fetch specification extracts a content type from any `BodyInit`: | Body you pass | Content-Type the server sees | | --- | --- | | `string` | `text/plain;charset=UTF-8` | | `URLSearchParams` | `application/x-www-form-urlencoded;charset=UTF-8` | | `FormData` | `multipart/form-data; boundary=…` | | `Blob` | whatever the blob's `type` is, or none if empty | | `ArrayBuffer` / typed array | none | `JSON.stringify(payload)` produces a string, so the beacon is labelled `text/plain`. The bytes on the wire are perfectly good JSON; only the label is wrong. A server that dispatches on `Content-Type` — a framework body parser configured for `application/json`, for instance — will hand your handler an empty body and the data looks lost. ## Option 1: accept text/plain on the server The least fragile fix. Configure the collector to read the raw body and `JSON.parse` it regardless of the declared type. This is what dedicated analytics endpoints usually do, and it has a real advantage beyond convenience: `text/plain` is one of the three CORS-safelisted content types, alongside `application/x-www-form-urlencoded` and `multipart/form-data`. A cross-origin POST carrying one of those needs no preflight, so the beacon is a single request that can be fired and forgotten. ## Option 2: send a typed Blob ```js const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' }); navigator.sendBeacon('/collect', blob); ``` This genuinely sets the header. Same-origin, it is a clean solution. Cross-origin it is a trap: `application/json` is *not* CORS-safelisted, so the request is no longer a simple one and the browser must send an `OPTIONS` preflight first and wait for the response before the real POST goes out. Two round trips fired as the document is being destroyed is exactly the situation you were trying to escape, and the endpoint must also be configured to answer the preflight with the right `Access-Control-Allow-Headers` and `Access-Control-Allow-Methods`. Many teams discover this as "our beacons work in staging and vanish in production", staging being same-origin. ## Option 3: use fetch with keepalive ```js fetch('/collect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), keepalive: true, }); ``` Same delivery guarantee as a beacon, plus arbitrary headers, an explicit `credentials` choice, and a `Response` you can inspect if the page is still alive. The same preflight caveat applies cross-origin — that is a CORS rule about the content type, not a beacon quirk — but at least you can see the failure, because the promise rejects instead of returning a bare `false`. ## Option 4: sidestep the content type entirely If the server is easier to change than the client is, `URLSearchParams` gives you a safelisted `application/x-www-form-urlencoded` body with real key/value structure and no preflight: ```js navigator.sendBeacon('/collect', new URLSearchParams({ event: 'exit', ms: String(t) })); ``` It is not JSON, but for flat counter payloads it is often a better fit, and it is the choice with the fewest moving parts cross-origin. ## The rule to remember With a beacon, the body type *is* the header. Pick the body type for the `Content-Type` you want the server to see, and remember that only `text/plain`, `application/x-www-form-urlencoded` and `multipart/form-data` avoid a preflight cross-origin. When you need a content type outside that set, use `fetch` with `keepalive` so you at least keep visibility into what went wrong.

  • Why does the typed-Blob approach work same-origin but lose data cross-origin?
    Same-origin there is no CORS check at all, so the request goes straight out. Cross-origin, application/json is not a safelisted content type, so the browser must complete an OPTIONS preflight before the POST. Two round trips started as the document is destroyed often do not finish, and the payload is lost.
  • Which content types avoid a preflight on a cross-origin POST?
    Only the CORS-safelisted ones: text/plain, application/x-www-form-urlencoded and multipart/form-data. Anything else, including application/json, makes the request non-simple and requires the browser to preflight it with OPTIONS before sending the body.
  • Is the payload actually corrupted when it arrives as text/plain?
    No. The bytes are exactly the JSON you serialized; only the declared media type differs. The failure is on the server side, where a body parser keyed on application/json skips the request and hands the handler an empty body. Reading the raw body and parsing it fixes it.

saying these in an interview costs you the question

  • Believes sendBeacon takes a headers option
  • Assumes the server always parses the raw body regardless of type
  • Ships a Blob typed application/json cross-origin without checking preflight
  • Says text/plain means the bytes are not valid JSON
  • Thinks beacons are exempt from CORS rules

context