DEV Community

Vladimir Elchinov for Session Replay

Posted on

A CORS Error on a POST Does Not Mean the POST Failed

Somebody clicks Pay. The console says the request was blocked by CORS policy, the button says something went wrong, and they click it again.

Did you just charge them twice, or not at all? The answer depends on a header you probably did not choose on purpose, and nothing your JavaScript received can tell you which.

"The browser blocked the request" is half right

That is how CORS errors usually get explained, and for one kind of request it is true. For the other kind, the request went out, the server ran it, and the only thing the browser blocked was your script reading the answer.

The Fetch Standard is explicit about the order. In the step that actually goes to the network:

Set response and internalResponse to the result of running HTTP-network-or-cache fetch given fetchParams. If request’s response tainting is "cors" and a CORS check for request and response returns failure, then return a network error.

The fetch happens first. The check is applied to a response that has already come back, from a server that has already done whatever the request asked. A failed check discards the response. It cannot undo the work.

Which kind of request you sent

Some cross-origin requests get a preflight first: an OPTIONS request asking permission before the real one is sent. When the preflight fails, the spec returns the network error at that point, and the real request never leaves the browser. Nothing was written.

Others skip the preflight entirely. The spec's rule is that a preflight is needed when the method is not a CORS-safelisted method, or when there is a header that is not safelisted. The safelisted methods are GET, HEAD and POST. For Content-Type, only three values pass: application/x-www-form-urlencoded, multipart/form-data and text/plain.

Now look at what fetch puts on a body you did not label. A string body gets text/plain;charset=UTF-8, URLSearchParams gets the form type, and FormData gets multipart. All three are on the safelist.

// No preflight. This POST is sent, and the server handles it,
// whatever happens to the CORS check afterwards.
fetch('https://api.example.com/orders', {
  method: 'POST',
  body: JSON.stringify(order)  // Content-Type: text/plain;charset=UTF-8
})

// Preflighted. OPTIONS goes first. If it fails, this POST is never sent.
fetch('https://api.example.com/orders', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(order)
})
Enter fullscreen mode Exit fullscreen mode

The first one is the easiest call to write and the one people write when they forget the header. Whether it writes anything depends on whether the server reads a body labelled text/plain. Some frameworks ignore it; anything that parses the raw body will act on it.

Your code cannot tell the two apart

Both calls fail the same way. When the result is a network error, the spec says to reject the promise with a TypeError. That is the same TypeError a dropped connection produces, so catch sees an identical error whether the order was written, half written, or never sent. The status code and the response body never reach your script.

Which makes the obvious retry dangerous:

async function placeOrder(order) {
  try {
    return await fetch(url, { method: 'POST', body: JSON.stringify(order) })
  } catch {
    return fetch(url, { method: 'POST', body: JSON.stringify(order) }) // second order
  }
}
Enter fullscreen mode Exit fullscreen mode

On a simple request with broken CORS, that is two orders and a user who saw two errors.

And neither can the bug report

The person reporting it says the same sentence either way: "it gave me an error, so I tried again." Their screen looked identical in both cases. Their description cannot distinguish a write that happened from one that did not, because they never saw anything that could.

What can distinguish them is the network traffic. A preflight failure leaves an OPTIONS request to that URL with no POST after it. A simple request leaves a POST that reached the server, which you can confirm in the server's own access log at that minute. One caveat: a successful earlier preflight can be cached, so a missing OPTIONS in a capture is not proof on its own. The server log is the witness that cannot be wrong about whether the write arrived.

So before anyone refunds, retries or replies "nothing went through", check the log.

What to do about it

  • Label JSON as JSON. Setting Content-Type: application/json forces the preflight, so a CORS misconfiguration fails before the server acts instead of after.
  • Do not retry a write on TypeError. It does not mean nothing happened. It means you do not know.
  • Make writes idempotent. An idempotency key on the request lets the server recognise the second attempt as the first one again.
  • Triage a CORS report on a write as "unknown outcome", not "failed". The browser said it would not show you the answer. It did not say there was no answer.

Top comments (0)