Skip to content
Fixing API errors

Preflight OPTIONS requests explained, with real traces

Why the browser sends an OPTIONS request you never wrote, what turns a simple request into a preflighted one, and how to stop doubling your request count.

SandyPublished 6 min read
Two police officers and a soldier stand guard at entrance, illustrating preflight options requests explained, with real traces
Photo by Sushanta Rokka on Unsplash.

You wrote one fetch call. The Network tab shows two requests, and the first one is an OPTIONS you never wrote:

OPTIONS  https://api.example.com/items   204   42ms
POST     https://api.example.com/items   201   88ms

That first request is a CORS preflight. The browser is asking the server for permission before sending your actual request.

Short answer

The browser sends a preflight before any cross-origin request that is not "simple". Your request becomes non-simple if it uses a method other than GET, HEAD or POST, sends a header outside the safelist, or sets Content-Type: application/json. The server must answer the OPTIONS request with matching Access-Control-Allow-* headers before the real request is sent. Cache that answer with Access-Control-Max-Age.

Why this exists

CORS was added to a web that already had decades of servers written before it. Those servers assumed that any request reaching them came from their own pages.

A plain HTML form could already POST to any origin, so allowing simple cross-origin POSTs changed nothing. But allowing arbitrary methods and headers would have been new, and could have let a malicious page issue a DELETE against an old server that never anticipated cross-origin traffic.

So the browser asks first. The preflight means: I am about to send a DELETE with an Authorization header. Is that acceptable? Only on a yes does the real request go out.

What makes a request "simple"

A request skips the preflight only if all of these hold:

Method is GET, HEAD or POST.

Headers are limited to the CORS-safelisted set: Accept, Accept-Language, Content-Language, Content-Type, Range, and a few the browser controls itself. Any custom header, including Authorization or X-Requested-With, forces a preflight.

Content-Type, if present, is one of application/x-www-form-urlencoded, multipart/form-data or text/plain.

That third rule catches almost everyone:

// Simple. No preflight.
fetch(url, { method: 'POST', body: 'a=1&b=2' });
 
// Preflighted, purely because of the Content-Type.
fetch(url, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ a: 1, b: 2 }),
});

A real trace

Here is what the browser sends for a preflighted request. The preflight first:

OPTIONS /items HTTP/1.1
Host: api.example.com
Origin: https://yourapp.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type

Note there is no body and no credentials. It is purely a question, described by those two Access-Control-Request-* headers.

The server's answer:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://yourapp.com
Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

The browser checks that the method and every requested header appear in that response. If anything is missing, the real request is never sent and you get a CORS error naming the specific header that was not allowed.

Only then does the actual request go:

POST /items HTTP/1.1
Host: api.example.com
Origin: https://yourapp.com
Authorization: Bearer abc123
Content-Type: application/json
 
{"name":"example"}

And its response must also carry Access-Control-Allow-Origin. This is the detail that catches people out.

The preflight passes, the real request fails

A very common failure mode, and the cause is nearly always structural. Many frameworks handle OPTIONS in a middleware layer that short-circuits before the route runs, so the preflight gets its headers and the actual response does not.

// Broken: the CORS headers only reach the OPTIONS response.
app.options('*', (req, res) => {
  res.set('Access-Control-Allow-Origin', 'https://yourapp.com');
  res.sendStatus(204);
});
 
app.post('/items', (req, res) => {
  res.json({ ok: true }); // no CORS header, so the browser blocks it
});

Use a middleware that applies to every response instead:

import cors from 'cors';
 
app.use(cors({ origin: 'https://yourapp.com' }));

Both responses need the header. The preflight asks permission; the real response proves permission still applies.

Caching the answer

Without caching, the browser preflights every single request, doubling your round trips. Access-Control-Max-Age tells it how long to remember the answer:

Access-Control-Max-Age: 86400

Browsers cap this regardless of what you send. Chromium honours up to 2 hours, Firefox up to 24 hours. Setting 86400 is still sensible: each browser takes what it will accept.

The cache key includes the origin, the URL, the method and the header set, so a preflight for POST /items does not cover DELETE /items.

Avoiding preflights where you can

For read-only public data, staying inside the simple-request rules removes the extra round trip entirely:

// Preflighted: the custom header forces it.
fetch(url, { headers: { 'X-Client': 'myapp' } });
 
// Simple: no preflight, one round trip.
fetch(`${url}?client=myapp`);

This only works for APIs that need no credentials, since Authorization always triggers a preflight. Which is another reason no-key APIs are pleasant to work with from the browser: they can stay simple, and simple requests are measurably faster.

Every API in our browser-ready collection needs no key and sends CORS headers, so a plain GET against them is a single round trip with no preflight at all.

Debugging checklist

SymptomCause
OPTIONS appears for a GETA custom header or Content-Type made it non-simple
Preflight returns 404The server has no OPTIONS handler for that route
Preflight returns 405The route exists but rejects OPTIONS
CORS error naming a headerThat header is missing from Access-Control-Allow-Headers
Preflight passes, real request blockedThe actual response is missing Access-Control-Allow-Origin
Preflight on every requestNo Access-Control-Max-Age
Works without auth, fails with itAuthorization forces a preflight the server does not handle

What triggers a preflight, and what does not

The rule is narrower than most people assume, and knowing it exactly is what lets you avoid preflights rather than merely tolerate them.

A request skips the preflight — it is "simple" — only if all three of these hold. The method is GET, HEAD or POST. The headers are limited to a short allowed set, essentially Accept, Accept-Language, Content-Language and Content-Type. And if Content-Type is present, it is one of exactly three values: application/x-www-form-urlencoded, multipart/form-data or text/plain.

Everything else preflights. That includes the two things almost every JSON API does: sending Content-Type: application/json, and sending an Authorization header. Which is why, in practice, nearly every authenticated API call from a browser costs two round trips rather than one.

The definition is historical rather than logical. Those three content types are the ones an HTML form could already produce without JavaScript, so allowing them introduced no new capability. Anything beyond that was new, and the preflight exists to let a server refuse it.

Two consequences worth acting on. A custom header — X-Request-Id, X-Client-Version — turns an otherwise simple GET into a preflighted one, so adding telemetry headers to a hot path doubles its request count. And a PUT or DELETE always preflights regardless of headers, which is one small argument for POST-based designs in browser-heavy applications.

Making preflights cheap

You usually cannot eliminate them, and you can stop paying for them repeatedly.

Access-Control-Max-Age tells the browser how long to cache the preflight result for that method and URL combination. With it set, the first call pays two round trips and subsequent calls pay one, for the duration. Without it, every single request preflights, which on a chatty interface doubles the request count for no benefit.

The caveat is that browsers cap the value regardless of what you send — Chromium at two hours, Firefox at twenty-four. Sending a week is harmless and does not achieve a week. The cache is also keyed per origin, per URL and per method, so a client hitting many distinct paths still preflights each one the first time.

Two smaller wins. Preflights are only sent cross-origin, so serving your API from the same origin as your page — through a path prefix rather than a separate subdomain — removes them entirely. And keeping the allowed header list tight is worth it for reasons beyond performance, since Access-Control-Allow-Headers: * is broader than almost any API needs.

When debugging, remember the preflight is a separate entry in the Network tab with method OPTIONS, and browsers sometimes hide it. If a request appears to fail with no response, look for the OPTIONS first — the failure is usually there rather than in the request you were watching.

Worth remembering

The preflight is not an error. It is the browser doing exactly what the specification requires, and seeing one in the Network tab tells you your request is non-simple rather than that something is broken.

When it does fail, the error message names the specific method or header that was rejected. That message is precise, unlike most CORS errors, so read it carefully before changing anything.

Common questions

Why does the browser send an OPTIONS request I did not write?

It is a CORS preflight. Before sending a cross-origin request that could change server state or carries unusual headers, the browser asks the server whether that request is permitted. Your actual request only goes out if the answer is yes.

How do I avoid a preflight entirely?

Keep the request simple: use GET, HEAD or POST, send only safelisted headers, and use a Content-Type of application/x-www-form-urlencoded, multipart/form-data or text/plain. Sending application/json on a POST triggers a preflight on its own.

Does the preflight double my rate limit usage?

Usually not, because most APIs do not count OPTIONS against quota, but it does double the round trips. Setting Access-Control-Max-Age lets the browser cache the answer and skip the preflight on subsequent requests.

Why does my preflight succeed but the real request still fail?

They are separate requests and both must pass CORS. A common cause is a server that handles OPTIONS in middleware but omits Access-Control-Allow-Origin on the actual response.

Can I make the preflight faster?

Set Access-Control-Max-Age so the result is cached. Browsers cap this: Chromium honours up to 2 hours and Firefox up to 24 hours, regardless of a larger value.

Sources

Written by

Sandy

I build and run this site on my own: the crawler that assembles the catalogue, the checker that probes every listing, and the writing. Before this I built SaveFromInternet and GrabReels, which meant living with other people’s APIs full time — parsers breaking when a platform shipped a change, rate limits arriving without warning, endpoints disappearing overnight. This directory exists because I got tired of free API lists that had never been checked.

APIs mentioned in this article

DWD API

Weather

API of the German Weather Service (DWD): weather data, data from specific weather stations, warnings (local, coast, sea, alps)

No keyCORSHTTPS

Verified 4 days ago: 100% uptime

View Details

Postcodes.io

Geocoding

Free UK postcode lookup API and datasets. Search, validate and reverse geocode postcodes. Open sourced project.

No keyCORSHTTPS

Verified 4 days ago: 100% uptime

View Details

Read next