CORS performance usually gets treated like background noise until your frontend starts making hundreds of API calls and every second request triggers an OPTIONS preflight. Then it becomes very obvious: bad CORS config can waste latency, server CPU, and CDN cache efficiency.

I’ve seen teams obsess over query performance while every browser quietly burns extra round trips on preflights they could have avoided.

This guide is the practical version: what gets cached, what does not, and what headers to set when you want cross-origin requests to be fast without turning your policy into mush.

The two kinds of CORS caching that matter

There are really two separate cache stories:

  1. Preflight cache

    • Browser caches the result of an OPTIONS preflight.
    • Controlled mainly by Access-Control-Max-Age.
  2. Actual response cache

    • The real GET/POST response may be cached by the browser, a CDN, or a proxy.
    • Controlled by normal HTTP caching headers like Cache-Control, ETag, and Vary.

People mix these up all the time. Access-Control-Max-Age does not cache your API response body. It only caches permission to make the cross-origin request.

What causes a preflight

A browser sends a preflight when a request is “non-simple”. Common triggers:

  • Method is not GET, HEAD, or POST
  • Content-Type is not one of:
    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain
  • Custom headers like:
    • Authorization
    • X-Requested-With
    • X-API-Key

Example that usually triggers preflight:

fetch("https://api.example.com/profile", {
  method: "PATCH",
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Bearer token"
  },
  body: JSON.stringify({ name: "Ada" })
});

The browser will first send something like:

OPTIONS /profile HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: content-type, authorization

If your server responds correctly, the browser may cache that permission.

Use Access-Control-Max-Age aggressively, but not blindly

This is the main performance lever for preflight traffic.

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

That tells the browser it can reuse the preflight result for up to 24 hours.

A good baseline:

  • 600 seconds: conservative
  • 3600 seconds: solid default
  • 86400 seconds: great when your CORS policy rarely changes

The catch: browser caps vary. Some browsers won’t honor the full value. Still worth setting a high number because the browser will clamp it if needed.

Express example

import express from "express";

const app = express();

app.use((req, res, next) => {
  const origin = req.headers.origin;
  const allowedOrigins = new Set([
    "https://app.example.com",
    "https://admin.example.com"
  ]);

  if (allowedOrigins.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin");
  }

  res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
  res.setHeader("Access-Control-Max-Age", "86400");

  if (req.method === "OPTIONS") {
    return res.status(204).end();
  }

  next();
});

app.get("/api/data", (req, res) => {
  res.json({ ok: true });
});

app.listen(3000);

If you dynamically reflect the request origin, add Vary: Origin. Without it, shared caches can serve the wrong CORS headers to the wrong site.

Avoid preflights when the request does not need them

Sometimes the fastest preflight is the one you never trigger.

If you control both frontend and backend, ask whether you really need:

  • custom headers
  • JSON for tiny writes
  • non-simple methods

A classic tradeoff:

Triggers preflight

fetch("https://api.example.com/search", {
  method: "POST",
  headers: {
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ q: "cors" })
});

Can avoid preflight

const body = new URLSearchParams({ q: "cors" });

fetch("https://api.example.com/search", {
  method: "POST",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded"
  },
  body
});

I wouldn’t contort an API just to dodge preflight everywhere. That gets ugly fast. But for hot paths like search suggestions, telemetry, or lightweight form submits, it can be worth it.

Cache the actual API response separately

A preflight cache hit still doesn’t mean your API response is cached.

For read-heavy endpoints, use normal HTTP caching:

Cache-Control: public, max-age=60, s-maxage=300
ETag: "user-list-v42"
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin, Accept-Encoding

This gives you:

  • browser cache for 60 seconds
  • shared cache/CDN freshness for 300 seconds
  • validation via ETag

Example with conditional requests

GET /users HTTP/1.1
Origin: https://app.example.com
If-None-Match: "user-list-v42"

Server:

HTTP/1.1 304 Not Modified
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin, Accept-Encoding
ETag: "user-list-v42"
Cache-Control: public, max-age=60, s-maxage=300

That still needs correct CORS headers on the 304. Missing them breaks browsers in annoying ways.

Vary is where CDN cache efficiency lives or dies

If your CORS policy changes per origin, your cache key probably needs to vary by origin.

Dynamic origin reflection

Access-Control-Allow-Origin: https://app.example.com
Vary: Origin

That is correct when multiple allowed origins exist.

Public API with wildcard

Access-Control-Allow-Origin: *

No Vary: Origin needed there, because the response is the same for everyone.

GitHub’s API is a good real-world example of a public CORS posture:

access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning

That wildcard is cache-friendly because every origin gets the same answer. If your API is truly public and does not use credentials, * is the simplest and fastest option.

Expose only the response headers your frontend actually reads

Browsers hide most response headers from JavaScript unless you expose them.

Example:

Access-Control-Expose-Headers: ETag, Link, X-RateLimit-Remaining, X-RateLimit-Reset

That matters for performance too. Why? Because exposed headers often drive client-side caching and pagination behavior.

For example, if your frontend reads ETag, it can make conditional requests. If it reads Link, it can paginate without parsing response bodies for navigation metadata.

Nginx example

location /api/ {
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin https://app.example.com always;
        add_header Access-Control-Allow-Methods "GET, POST, PATCH, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
        add_header Access-Control-Max-Age 86400 always;
        add_header Vary Origin always;
        return 204;
    }

    add_header Access-Control-Allow-Origin https://app.example.com always;
    add_header Access-Control-Expose-Headers "ETag, Link, X-RateLimit-Remaining" always;
    add_header Vary Origin always;

    proxy_pass http://backend;
}

Credentials make caching harder

Once you send cookies or HTTP auth cross-origin, things get stricter.

You cannot use:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

That combination is invalid.

Instead:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

Credentialed responses are also much less cache-friendly in shared layers. If a response is user-specific, don’t expect your CDN to save you unless you’ve designed around that carefully.

My rule: if an endpoint can be public and token-based without cookies, life gets easier. Browser cookie auth plus cross-origin caching is where clean architectures go to suffer.

Don’t forget preflight responses should be cheap

Your OPTIONS handler should not hit the database, session store, or auth backend.

Bad:

  • loading user session
  • doing tenant lookup from DB
  • generating full app middleware stack

Good:

  • check Origin
  • check requested method/headers
  • return 204

Example in Go:

func cors(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        origin := r.Header.Get("Origin")

        if origin == "https://app.example.com" {
            w.Header().Set("Access-Control-Allow-Origin", origin)
            w.Header().Set("Vary", "Origin")
        }

        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
        w.Header().Set("Access-Control-Max-Age", "86400")

        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusNoContent)
            return
        }

        next.ServeHTTP(w, r)
    })
}

A practical checklist

If CORS is slowing down your API, this is where I’d start:

  • Set Access-Control-Max-Age to 3600 or higher
  • Return 204 No Content for preflight
  • Keep OPTIONS handlers stateless and cheap
  • Avoid custom headers on hot paths when possible
  • Use Access-Control-Allow-Origin: * for truly public, non-credentialed APIs
  • Add Vary: Origin when origin-specific responses are used
  • Cache real responses with Cache-Control and ETag
  • Include CORS headers on 304 Not Modified responses too
  • Expose headers your frontend needs for pagination, rate limits, and validation

Copy-paste starter configs

Public API

Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: ETag, Link, Retry-After, X-RateLimit-Remaining, X-RateLimit-Reset
Access-Control-Max-Age: 86400
Cache-Control: public, max-age=60, s-maxage=300
ETag: "v1-resource"

Private SPA API

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Expose-Headers: ETag, Link
Access-Control-Max-Age: 3600
Vary: Origin
Cache-Control: private, max-age=0, must-revalidate

Fast preflight response

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

CORS performance is mostly about reducing needless variation and needless round trips. Keep the policy predictable, keep preflights cacheable, and let normal HTTP caching do the heavy lifting for the actual data. That’s where the wins are.