Ch. 20 · Networking

HTTP Preflight Requests and Allowed Operations

What makes a browser send an OPTIONS preflight, how the server's answer gates the real request, and five preflights traced in a real browser.

~6 min readintermediateupdated Oct 11, 2026

If you’ve built a single-page app that calls an API on another origin, you’ve seen OPTIONS requests appear in the network panel before your real calls. That’s a CORS preflight: the browser asking the server whether it accepts a particular kind of request before sending it. Interviewers like this topic because it ties together HTTP methods, headers, browser security and latency, and because preflight failures are a common, confusing production bug.

This note shows what triggers a preflight, what the exchange looks like, and five requests traced through a real Chromium browser against a local API.

Why browsers ask first

Before CORS existed, a web page could already make some cross-origin requests: a form could submit a GET or POST with a few content types. Servers were written assuming that was all a foreign page could do. They didn’t expect a cross-site DELETE, or a request with a JSON body and a custom header.

When browsers gained the ability to send those through fetch, sending them unannounced could have caught old servers off guard. So the rule became: anything a form couldn’t do needs the server’s explicit permission first. Requests a form could do (“simple” requests) go straight out, and the browser only decides afterwards whether the page may read the response.

What triggers a preflight

A request is preflighted if any of these are true:

  • The method isn’t GET, HEAD or POST. PUT, PATCH and DELETE always ask.
  • The Content-Type isn’t one of text/plain, application/x-www-form-urlencoded or multipart/form-data. So application/json triggers one.
  • It sets a header outside the small safelisted set (Accept, Accept-Language, Content-Language, and Content-Type with the values above). Authorization isn’t on the list, nor is any X- header.

In practice, a typical SPA call with a JSON body and a bearer token is preflighted every time.

The exchange, header by header

You can send a preflight by hand with curl to see what the server answers:

curl -si -X OPTIONS http://localhost:8096/api/items/1 \
  -H 'Origin: http://localhost:5173' \
  -H 'Access-Control-Request-Method: PUT' \
  -H 'Access-Control-Request-Headers: authorization,content-type'
Terminal
HTTP/1.1 204 No Content
access-control-allow-origin: http://localhost:5173
vary: Origin
access-control-allow-methods: GET, POST, PUT
access-control-allow-headers: authorization, content-type
access-control-max-age: 600
Text

The browser describes the request it wants to make: which origin it comes from, which method, which non-safelisted headers. The server answers with what it allows. If the origin, the method and every requested header are covered, and the status is 2xx, the browser sends the real request. Access-Control-Max-Age: 600 lets the browser remember the answer for ten minutes.

The test API that produced this logs every request, including what each preflight asks for. One route, /api/strict, checks for an Authorization header before doing anything else, which turns out to matter:

http.createServer((req, res) => {
  // A route whose auth check runs before CORS handling: the classic preflight bug.
  if (req.url === '/api/strict' && !req.headers.authorization) { res.statusCode = 401; return res.end(); }

  if (req.headers.origin === ALLOWED_ORIGIN) res.setHeader('access-control-allow-origin', ALLOWED_ORIGIN);
  res.setHeader('vary', 'Origin');

  if (req.method === 'OPTIONS') {
    res.setHeader('access-control-allow-methods', 'GET, POST, PUT');
    res.setHeader('access-control-allow-headers', 'authorization, content-type');
    res.setHeader('access-control-max-age', '600');
    res.statusCode = 204;
    return res.end();
  }
  res.setHeader('content-type', 'application/json');
  res.end(JSON.stringify({ ok: true, method: req.method }));
}).listen(8096, '127.0.0.1');
JavaScript

Five requests through a real browser

A page served from http://localhost:5173 made five calls in sequence, all with a JSON content type and a bearer token:

const json = { 'content-type': 'application/json', authorization: 'Bearer demo-token' };
await attempt('1 PUT  json + auth     ', api + '/api/items/1', { method: 'PUT', headers: json, body: '{"qty":2}' });
await attempt('2 PUT  again (cached?) ', api + '/api/items/1', { method: 'PUT', headers: json, body: '{"qty":3}' });
await attempt('3 DELETE               ', api + '/api/items/1', { method: 'DELETE', headers: json });
await attempt('4 PUT  + X-Request-Id  ', api + '/api/items/1', { method: 'PUT', headers: { ...json, 'x-request-id': 'r-42' }, body: '{}' });
await attempt('5 POST /api/strict     ', api + '/api/strict', { method: 'POST', headers: json, body: '{}' });
JavaScript

What the page saw:

1 PUT  json + auth     : 200 {"ok":true,"method":"PUT"}
2 PUT  again (cached?) : 200 {"ok":true,"method":"PUT"}
3 DELETE               : TypeError: Failed to fetch
4 PUT  + X-Request-Id  : TypeError: Failed to fetch
5 POST /api/strict     : TypeError: Failed to fetch
Text

What the API received:

api: OPTIONS /api/items/1  (asks for PUT + headers: authorization,content-type)
api: PUT /api/items/1
api: PUT /api/items/1
api: OPTIONS /api/items/1  (asks for DELETE + headers: authorization,content-type)
api: OPTIONS /api/items/1  (asks for PUT + headers: authorization,content-type,x-request-id)
api: OPTIONS /api/strict  (asks for POST + headers: authorization,content-type)
Text

And the browser console:

... has been blocked by CORS policy: Method DELETE is not allowed by Access-Control-Allow-Methods in preflight response.
... has been blocked by CORS policy: Request header field x-request-id is not allowed by Access-Control-Allow-Headers in preflight response.
... has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Text

Reading them together:

  1. The first PUT cost two requests: the preflight, then the PUT.
  2. The second PUT went straight through. The Max-Age answer was cached, so no OPTIONS.
  3. DELETE wasn’t in the allowed methods. The log shows only the OPTIONS: the DELETE itself never left the browser. This is the key difference from a simple request, which reaches the server and runs even when the page can’t read the response.
  4. The extra X-Request-Id header wasn’t allowed, so that PUT never left either. A tracing library adding a header can break every API call this way.
  5. /api/strict rejected the preflight with 401. Browsers never attach Authorization headers or cookies to a preflight; it’s an anonymous question. Authentication middleware that runs before CORS handling rejects every preflight, and the console blames a missing CORS header, which sends people looking in the wrong place.

What preflights cost, and how to reduce it

A preflight is a full extra round trip before the real request. On a 200 ms mobile connection, an uncached preflight doubles the latency of a call.

Access-Control-Max-Age helps, but less than people expect. Browsers cap it (Chromium at two hours, Firefox at a day), and without the header the cache lasts only five seconds. More importantly, the cache is keyed by URL as well as origin, so /api/items/1 and /api/items/2 each need their own preflight. A REST API with IDs in its paths keeps paying.

The most effective fix is not being cross-origin at all: serve the API under the app’s own origin (app.example.com/api, through a reverse proxy or CDN path rule) and no preflight happens. Don’t twist requests into “simple” ones to avoid preflights, such as sending JSON as text/plain. That brings back exactly the cross-site request risk preflights exist to prevent.

Two smaller gotchas: Access-Control-Allow-Headers: * is allowed for requests without credentials, but the wildcard doesn’t cover Authorization, which must always be listed by name. And blocking OPTIONS at a firewall or API gateway, because “we don’t use that method”, breaks every preflighted call.

Interview practice

Your SPA at app.example.com calls api.example.com. Users on slow mobile networks say the app feels sluggish, and the network panel shows an OPTIONS request before almost every API call. Meanwhile, a new “Delete” button fails with a CORS error, though editing (PUT) works. Explain both and say how you’d fix them.

Answer

Every call is preflighted because it uses a JSON content type and an Authorization header, and the preflights are rarely cached: either the API sends no Access-Control-Max-Age, so the browser keeps the answer for only five seconds, or each call hits a different URL (different item IDs), and the cache is per URL. On a high-latency connection each uncached preflight adds a full round trip.

The Delete failure is a preflight rejection: Access-Control-Allow-Methods probably lists GET, POST and PUT but not DELETE. The console message will name it directly. Adding DELETE fixes it. Since a failed preflight means the DELETE was never sent, there’s no data cleanup to worry about.

For latency, the best fix is to remove the cross-origin hop: route app.example.com/api/* to the API through the CDN or reverse proxy, so requests are same-origin and preflights disappear. If that isn’t possible, I’d set Access-Control-Max-Age to the browser maximum and make sure OPTIONS is answered quickly by the CORS layer, before authentication, and isn’t blocked by any gateway rule. I’d also check that no client library is adding unnecessary custom headers.

Keep learning

More in Networking

read ✓Networking · mid

CORS and Browser Response Access

CORS decides whether a page may read a cross-origin response, not whether it is sent. A browser test shows a blocked POST still saved.

~6 min readread →
read ✓Networking · mid

Networking: DNS Caching and TTL

See how resolvers count TTLs down, why NXDOMAIN answers are cached too, and how to schedule a DNS cutover so stale answers expire.

~7 min readread →
read ✓Networking · easy

Networking: DNS Record Types

Read real dig output for A, AAAA, CNAME, MX, TXT, NS, SOA and CAA records, and learn which combinations DNS rejects and why.

~6 min readread →
esc