🔄 Every Request Secretly Sends Two
A single Ajax call with a custom header or a JSON content type can silently double your API’s request volume — the browser sends an invisible OPTIONS preflight request before every “real” one, and if it’s not cached, that’s a second full round-trip every single time.
🐞 The Problem
fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Version': '2.4.1', // custom header - triggers a preflight
},
body: JSON.stringify(order),
});
// The browser sends an OPTIONS request FIRST to ask permission,
// then the actual POST - two requests for every one you wrote.
🔍 Why This Happens
A request only skips preflight if it's a "simple request": GET/ HEAD/POST only, with a small set of allowed headers, and Content-Type limited to a few specific values (not application/json). Almost any real API call - JSON bodies, auth headers, custom headers - fails at least one of those conditions and triggers a preflight OPTIONS request first.
✅ The Fix: Cache the Preflight Response
- The preflight itself can’t be avoided for a JSON API — but the BROWSER can cache its result so it doesn’t repeat for every subsequent call.
- Setting Access-Control-Max-Age tells the browser how long (in seconds) it can reuse a preflight result before asking again.
⏱️ Cache Preflight on the Server (ASP.NET Core Example)
builder.Services.AddCors(options =>
{
options.AddPolicy("Default", policy =>
policy.WithOrigins("https://app.example.com")
.AllowAnyHeader().AllowAnyMethod()
.SetPreflightMaxAge(TimeSpan.FromHours(1)));
// Browser won't re-send OPTIONS for this origin/method
// combo for a full hour after the first successful one.
});
⚠️ Also Worth Knowing
- Max-Age has a hard browser-enforced ceiling regardless of what you set (Chrome caps it around 2 hours) — don’t expect a 24-hour value to actually hold for 24 hours.
- If you truly need to avoid preflight entirely for a specific call, staying within “simple request” rules (no custom headers, no JSON content-type) is the only way — often not practical for a real API.
The preflight isn’t a bug or a misconfiguration — it’s the browser doing exactly what CORS asks of it; Max-Age just stops it from asking the same permission question over and over.
