Preflight Requests

Understanding the OPTIONS request that precedes complex cross-origin requests

When a cross-origin request is "complex" (uses methods other than GET/HEAD/POST, has custom headers, or uses certain Content-Types), the browser automatically sends a preflight OPTIONS request first.

The preflight checks if the actual request is allowed. Only if the server responds with the right CORS headers will the browser send the real request.

Preflight Flow

1

Browser detects complex request

Your code calls fetch() with custom headers or non-simple method

// This triggers preflight (custom header) fetch('https://api.example.com/data', { method: 'POST', headers: { 'Content-Type': 'application/json', // Triggers preflight! 'X-Custom-Header': 'value' // Triggers preflight! } });
2

Browser sends OPTIONS request

Asks the server what's allowed

OPTIONS /data HTTP/1.1 Host: api.example.com Origin: https://myapp.com Access-Control-Request-Method: POST Access-Control-Request-Headers: content-type, x-custom-header
3

Server responds with permissions

Lists allowed origins, methods, and headers

HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://myapp.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE Access-Control-Allow-Headers: Content-Type, X-Custom-Header Access-Control-Max-Age: 86400
4

Browser sends actual request

If preflight passed, the real request is sent

POST /data HTTP/1.1 Host: api.example.com Origin: https://myapp.com Content-Type: application/json X-Custom-Header: value {"data": "payload"}

What Triggers Preflight?

// These trigger preflight: // 1. Non-simple HTTP methods fetch(url, { method: 'PUT' }); // Preflight! fetch(url, { method: 'DELETE' }); // Preflight! fetch(url, { method: 'PATCH' }); // Preflight! // 2. Custom headers fetch(url, { headers: { 'X-API-Key': 'abc' } // Preflight! }); // 3. Content-Type other than simple types fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, // Preflight! body: JSON.stringify(data) }); // These do NOT trigger preflight: fetch(url); // Simple GET fetch(url, { method: 'POST' }); // POST without body fetch(url, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, // Simple type body: 'text data' });

Simple Content-Types (no preflight):

  • text/plain
  • application/x-www-form-urlencoded
  • multipart/form-data

Server-Side: Handling Preflight (Node.js)

import { createServer } from 'node:http'; const server = createServer((req, res) => { // Set CORS headers for all responses res.setHeader('Access-Control-Allow-Origin', 'https://myapp.com'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); // Handle preflight if (req.method === 'OPTIONS') { // Cache preflight response for 24 hours res.setHeader('Access-Control-Max-Age', '86400'); res.writeHead(204); // No content needed res.end(); return; } // Handle actual request if (req.method === 'POST' && req.url === '/api/data') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true })); } }); server.listen(3000);

Access-Control-Max-Age tells the browser how long to cache the preflight response. This avoids sending OPTIONS before every request.

Common Preflight Errors

// Error: Method not allowed // Server only allows: Access-Control-Allow-Methods: GET, POST fetch(url, { method: 'DELETE' }); // Fails! // Error: Header not allowed // Server only allows: Access-Control-Allow-Headers: Content-Type fetch(url, { headers: { 'X-Custom': 'value' } // Fails! }); // Error: Origin not allowed // Server only allows: Access-Control-Allow-Origin: https://other.com // Request from https://myapp.com will fail

When preflight fails, you'll see a CORS error in the console. The actual request is never sent — the browser blocks it at the preflight stage.

Debugging Preflight

In browser DevTools Network tab:

  • Look for OPTIONS requests (they appear before your actual request)
  • Check the response headers for Access-Control-Allow-*
  • If preflight fails, the actual request won't appear
  • The Console will show CORS error messages