loke.dev
Header image for Stop Paying the 'CORS Tax': How to Bypass Mandatory Pre-flight Checks for Sub-100ms APIs

Stop Paying the 'CORS Tax': How to Bypass Mandatory Pre-flight Checks for Sub-100ms APIs

Your high-performance backend is being throttled by a silent browser handshake—here is how to eliminate the 200ms pre-flight delay for good.

· 7 min read

I spent three days optimizing a Go-based microservice until it could handle requests in a blistering 12 milliseconds. I was proud. I pushed it to production, opened Chrome DevTools to celebrate, and watched in horror as the total "time to completion" for my API calls hovered around 240ms. I thought my network was lagging. I thought the load balancer was failing. Then I saw it: the OPTIONS request. That little pre-flight handshake was eating 90% of my performance budget before my actual code even had a chance to run.

This is the "CORS Tax." It’s an invisible performance killer that affects almost every modern web application, yet we often treat it as an unavoidable law of nature. It isn't.

If you’re building high-performance frontends, you can’t afford an extra round-trip to the server for every single mutation. Let’s look at why this happens and how we can actually kill the pre-flight check for good.

The Anatomy of the Tax

Cross-Origin Resource Sharing (CORS) is a security mechanism, not a performance feature. Its job is to ensure that a website at cool-app.com doesn't have the permission to talk to an API at api.service.com unless the API explicitly says it’s okay.

To verify this, the browser sends an "experimental" request using the OPTIONS method. It asks the server: "Hey, I’m about to send a POST request with a JSON body and an Authorization header. Is that cool?" The server responds, and only then does the browser send the actual data.

In a world where we strive for sub-100ms "Time to Interactive," adding a full network round-trip for a handshake is unacceptable. This is especially painful on mobile networks where latency might be 150ms per hop. Your 10ms API call just became a 310ms ordeal.

The "Simple Request" Loophole

The browser doesn't *always* send a pre-flight. There is a category of requests called "Simple Requests" that bypass the OPTIONS check entirely. If you can make your API calls fit into this tiny, restrictive box, the CORS tax disappears.

A request is "Simple" only if it meets all these criteria:
1. Method: Must be GET, HEAD, or POST.
2. Headers: Only a few "CORS-safelisted" headers are allowed (e.g., Accept, Accept-Language, Content-Language).
3. Content-Type: Must be one of:
* application/x-www-form-urlencoded
* multipart/form-data
* text/plain

The moment you add Content-Type: application/json or a custom header like X-Api-Key, the browser triggers a pre-flight.

Strategy 1: The "Text/Plain" Hack

The most common reason for a pre-flight is sending JSON. Every modern frontend framework defaults to Content-Type: application/json. But here’s the trick: the browser doesn't care what the *body* of your request looks like as long as the header says text/plain.

You can send a valid JSON string as text/plain. The browser sees it as a "Simple Request" and skips the OPTIONS check.

The Frontend Implementation

Instead of the usual fetch, you do this:

// This triggers a pre-flight (OPTIONS)
await fetch('https://api.example.com/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ user: 'neo' })
});

// This bypasses the pre-flight tax
await fetch('https://api.example.com/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'text/plain' // The magic bullet
  },
  body: JSON.stringify({ user: 'neo' })
});

The Backend Implementation (Node.js/Express)

Your backend now needs to be smart enough to treat text/plain as JSON. Most body-parser middlewares won't do this by default, so you have to nudge them.

const express = require('express');
const app = express();

// Tell express to parse text/plain bodies as JSON if they look like JSON
app.use(express.text({ type: 'text/plain' }));

app.post('/data', (req, res) => {
  let data;
  try {
    // If it's a string from our hack, parse it
    data = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
  } catch (e) {
    return res.status(400).send('Invalid JSON');
  }

  console.log('User:', data.user);
  res.json({ success: true });
});

The Catch: This only works if you don't need custom headers like Authorization: Bearer <token>. If you add that header, you're back to square one.

Strategy 2: Using the Access-Control-Max-Age Header

If you absolutely must use custom headers or application/json (which is often the case for enterprise APIs), you can't avoid the first pre-flight. But you *can* avoid the next ten thousand.

By default, browsers are incredibly conservative about caching the results of an OPTIONS request. In Chrome, the default cache duration is often just 5 seconds. If a user clicks two buttons 10 seconds apart, they pay the tax twice.

You can increase this using the Access-Control-Max-Age header in your CORS response.

Implementation (Nginx)

location /api/ {
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' 'https://cool-app.com';
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE';
        add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type';
        
        # Cache the pre-flight result for 24 hours (86400 seconds)
        add_header 'Access-Control-Max-Age' 86400;
        
        add_header 'Content-Type' 'text/plain; charset=utf-8';
        add_header 'Content-Length' 0;
        return 204;
    }
}

Note: Chromium browsers (Chrome, Edge) cap this value at 2 hours (7200 seconds), while Firefox allows up to 24 hours. Even a 2-hour window is a massive win for user experience.

Strategy 3: The Same-Origin Illusion (The Ultimate Fix)

CORS only exists because the browser thinks your frontend and backend are strangers. If they share the same origin, the "tax" is abolished.

I see many teams host their app at app.example.com and their API at api.example.com. To a browser, these are different origins. Instead, use a reverse proxy or a Content Delivery Network (CDN) to host both on the same domain.

* App: example.com/
* API: example.com/api/

Now, the browser sees no cross-origin risk. No OPTIONS requests. Ever.

Example: Nginx as a Reverse Proxy

This setup routes traffic to either your frontend build or your backend service based on the URL path.

server {
    listen 80;
    server_name example.com;

    # Frontend Assets
    location / {
        root /var/www/frontend;
        try_files $uri $uri/ /index.html;
    }

    # API Requests
    location /api/ {
        proxy_pass http://backend-service:8080/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

By moving the API behind the same domain, you don't just solve CORS—you also simplify cookie management (no more SameSite=None; Secure headaches) and potentially improve SEO by keeping all your traffic on a single domain.

Strategy 4: Edge-Side Handshakes (Cloudflare Workers)

If your infrastructure is already fragmented and you can't easily move everything behind a single Nginx instance, you can use a Serverless Edge Function (like Cloudflare Workers or AWS Lambda@Edge) to handle the pre-flights before they even hit your origin server.

This doesn't remove the pre-flight, but it moves the response significantly closer to the user. An OPTIONS request handled at an edge node 10ms away from the user is much better than one hitting your origin server 150ms away.

Cloudflare Worker Example

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  if (request.method === 'OPTIONS') {
    return new Response(null, {
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        'Access-Control-Max-Age': '86400',
      },
    })
  }
  
  // Forward actual requests to the real API
  return fetch(request)
}

When Should You Care?

I'm a big believer in not optimizing until you have a problem, but the CORS tax is unique because it's a silent drag on the *perception* of your app's speed.

You should prioritize bypassing pre-flights if:
1. Your users are on mobile: High latency makes the tax significantly more expensive.
2. You have "Chatty" APIs: If your UI performs many small requests (e.g., autosave, real-time updates), the overhead is compounding.
3. You're aiming for "Instant" feel: If your backend response time is already under 50ms, a 200ms pre-flight is literally your biggest bottleneck.

The Security Trade-off (The "Gotcha")

There is a reason CORS exists. By using the text/plain hack, you are essentially circumventing the browser's "pre-flight" protection. This protection was designed to prevent old-school servers (that don't understand JSON) from being accidentally hit by cross-origin scripts.

Modern APIs are almost always fine with this, but you must ensure your CSRF (Cross-Site Request Forgery) protection is solid. If you rely solely on Content-Type: application/json to protect against CSRF (because older browsers couldn't send JSON without a pre-flight), you are opening a door. Always use modern CSRF tokens or, better yet, rely on SameSite: Strict cookies.

Summary Checklist

If you want to kill the CORS tax today, here is your playbook:

1. Can you move everything to one domain? Use a reverse proxy (Nginx, Caddy, Cloudflare) to serve the API from /api on the same domain as your HTML. This is the "Gold Standard."
2. Can you use the JSON-as-Text hack? If you don't need custom headers, send your POST data as text/plain and parse it on the server.
3. Increase the Cache. At the very least, set Access-Control-Max-Age: 7200 on your server’s CORS configuration. Stop making the browser ask for permission every 5 seconds.
4. Avoid Custom Headers. Every header like X-Transaction-Id or X-App-Version is a trigger for a pre-flight. Use them sparingly or move that metadata into the request body if possible.

The web is slow enough as it is. Don't let a 20-year-old security protocol steal your performance gains. Stop paying the tax and start shipping APIs that actually feel as fast as they are.