The Hidden Request Your Browser Sends Before Calling an API
If you have ever opened the Chrome DevTools Network tab while developing a modern web application, you have likely run into a strange phenomenon: a mysterious OPTIONS request appearing right before your actual GET, POST, or PUT API call.
For beginners, this behavior is often confusing. It looks like the frontend is making two requests for every single action, doubling the network latency. Some developers even write backend logic to handle this "duplicate" request, assuming it's a bug in their Javascript code. But it isn't a bug. It is one of the most important security features of the modern web: the CORS Preflight Request.
In this post, we'll peel back the layers of the browser's network stack to understand what this request is, why it exists, which conditions trigger it, and how to configure your backend to handle it seamlessly.
The Problem CORS Solves: Same-Origin Policy (SOP)
To understand why your browser sends an extra request, we must first understand the Same-Origin Policy (SOP). Implemented by Netscape in 1995, SOP is a fundamental security model that prevents document scripts on one origin from accessing data from another origin.
Without SOP, the web would be a wild west of session hijacking. Imagine you are logged into your bank account at bank.com, and you accidentally visit a malicious website at evil.com. Without the Same-Origin Policy, a script running on evil.com could make a fetch request to bank.com/api/balance. Because your browser automatically includes your bank session cookies with the request, the bank would authenticate the call and send your private balance data straight back to evil.com.
To prevent this, browsers enforce a strict rule: scripts can only access resources from the same origin. But what exactly is an origin?
What Counts as a Different Origin?
An origin is defined by three components: Protocol (Scheme), Domain (Host), and Port. If any of these three do not match, the browser considers it a cross-origin request.
| Requested URL | Compared to http://localhost:3000 | Result |
|---|---|---|
| http://localhost:3000/api/users | Same protocol, domain, and port | Same-Origin (Allowed) |
| https://localhost:3000/api/users | Different protocol (https vs http) | Cross-Origin (Blocked) |
| http://127.0.0.1:3000/api/users | Different domain name (127.0.0.1 vs localhost) | Cross-Origin (Blocked) |
| http://localhost:8000/api/users | Different port (8000 vs 3000) | Cross-Origin (Blocked) |
Same-Origin vs. Cross-Origin Scenarios
1. A Normal Request Without CORS
If your frontend and backend are hosted on the same origin (for example, in a classic Monolith architecture where Next.js serves both pages and API endpoints at localhost:3000), the browser bypasses CORS entirely. The browser trusts the script because it belongs to the same domain, executing the HTTP request immediately and returning the data to your JavaScript code.
2. The Cross-Origin Request
In modern development, we usually decouple our stacks. Your Next.js or React frontend might run on http://localhost:3000, while your Express or Django backend runs on http://localhost:8000. When your frontend code calls fetch('http://localhost:8000/api/data'), the browser detects different origins and immediately stops the execution to check permissions.
When and Why Does the Browser Send an OPTIONS Request?
To handle cross-origin operations safely, the W3C introduced Cross-Origin Resource Sharing (CORS). CORS is a mechanism that uses additional HTTP headers to tell browsers to give a web application running at one origin access to selected resources from a different server.
But why does the browser send a preflight (an OPTIONS request) instead of just checking headers in the response? Why does it ask for permission first?
To protect legacy databases and servers from state-changing operations.
Before CORS was introduced, many web servers were written assuming only internal clients could make state-changing requests. If the browser immediately sent a DELETE /api/users/99 request from a cross-origin website, the server would execute the database deletion before returning the response. Even if the browser blocked the response from reaching the malicious script, the damage would already be done — the user's data would be deleted. By sending an OPTIONS request first, the browser says: "Hey, I have a client who wants to send a DELETE request. Do you support cross-origin requests from this domain? If not, I won't even send the actual DELETE."
It is critical to note that your JavaScript code does not trigger the preflight request. The browser intercepts your fetch call and automatically issues the OPTIONS request on its own thread before your code continues.
Which Requests Trigger a Preflight?
Not all requests require a preflight. The browser classifies calls into "Simple Requests" and "Preflighted Requests". A request triggers an OPTIONS preflight if it violates any of these simple rules:
- Non-Simple Methods: Using
PUT,DELETE, orPATCH. (OnlyGET,POST, andHEADare simple methods). - Custom Headers: Adding any custom headers like
Authorization,X-Requested-With, or custom token headers. - Non-Simple Content-Type: Setting the
Content-Typeheader to something other thanapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain. (This means sending JSON payload withapplication/jsonalways triggers a preflight!). - Credentials: If the request includes cookies or client-side certificates.
Inside the Preflight Request & Response
Let's look at what actually travels over the wire when your browser initiates a preflight. Imagine your frontend at http://localhost:3000 sends a POST request containing a JSON body and an Authorization header to http://localhost:8000/api/users.
1. The Outgoing Preflight (OPTIONS)
The browser first sends an HTTP request using the OPTIONS method. It includes headers specifying what the actual request is planning to do:
OPTIONS /api/users HTTP/1.1
Host: localhost:8000
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
Notice the three key headers here:
- Origin: Tells the server where the request originates.
- Access-Control-Request-Method: Tells the server what HTTP method the Javascript wants to use.
- Access-Control-Request-Headers: Lists the custom headers the client wants to send.
2. The Server's Response
The server must inspect these headers and respond, typically with a 204 No Content or 200 OK status, certifying that the cross-origin request is allowed:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
If the server's response matches or exceeds the client's requirements, the browser immediately follows up by sending the actual POST request. If the server fails to respond with these headers, the browser cancels the request sequence, printing a red CORS error in the console.
Caching the Preflight: Access-Control-Max-Age
Sending a preflight request before every single API call would double the latency of your application, destroying performance. To solve this, servers can include the Access-Control-Max-Age header in their preflight response.
This header tells the browser how long (in seconds) it is allowed to cache the preflight permission. In the example above, Access-Control-Max-Age: 86400 allows the browser to cache the result for 24 hours. During this period, any subsequent POST /api/users calls will bypass the OPTIONS preflight entirely, going straight to the server.
What app.use(cors()) Actually Does
In Node.js backends using Express, we usually handle this by mounting the cors middleware. Let's look at what a production configuration looks like under the hood:
import express from "express";
import cors from "cors";
const app = express();
app.use(cors({
origin: "http://localhost:3000",
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true,
maxAge: 86400 // Cache preflight for 24 hours
}));
Let's dissect each property in this setup:
- origin: Configures the
Access-Control-Allow-Originheader. It specifies which external domains are permitted to read resources. It should never be set to*in production if credentials are enabled. - methods: Configures the
Access-Control-Allow-Methodsheader, specifying the whitelist of HTTP verbs allowed for cross-origin actions. - allowedHeaders: Configures the
Access-Control-Allow-Headersheader, specifying which custom request headers the client is allowed to submit. - credentials: Configures the
Access-Control-Allow-Credentialsheader. When set to true, it permits the client to send cookies, authorization headers, or SSL certificates with cross-origin calls.
What If You Don't Use CORS?
A common misconception among frontend developers is that a CORS failure means the server rejected the request. That is incorrect.
The backend still receives and executes the request!
If your backend doesn't configure CORS, and your frontend issues a simple POST request, the browser sends the request, the backend processes the data, modifies the database, and returns the response payload. However, when the response reaches the browser, the browser notes the missing Access-Control-Allow-Origin header, intercepts the payload, and blocks your Javascript code from reading it. Your fetch code throws a generic Network Error, but the backend action was already successfully executed.
Why Postman Works
Developers often ask: "Why does my API work fine in Postman, curl, and mobile apps, but fails in Chrome?"
The answer is simple: Postman is not a web browser. CORS is a browser-enforced security model designed to protect users browsing the web. Since Postman is a developer tool and does not display untrusted third-party websites or manage browser cookies, it has no reason to enforce the Same-Origin Policy. It simply acts as a direct network client, bypassing the browser layer entirely.
Dynamic Origins and Production Risks
Sometimes developers need to allow multiple frontend environments (like staging, preview URLs, and production) to access the API. In Express, you might see code like this:
app.use(cors({
origin(origin, callback) {
// Dynamically reflect every incoming origin back as allowed
callback(null, true);
},
credentials: true
}));
While this looks convenient, it is extremely risky in production. This callback effectively reflects whatever the incoming Origin header is back into Access-Control-Allow-Origin, dynamically white-listing the entire internet. When combined with credentials: true, this completely nullifies Same-Origin Policy protections, allowing any malicious site to read credentials-backed APIs on behalf of your users. Instead of reflecting everything, validate incoming origins against a strict, predefined whitelist array.
Common CORS Myths Debunked
- Myth: CORS is a backend security feature.
Truth: CORS is a browser-side policy. It exists to protect browser users, not to secure backend servers from unauthorized access. - Myth: Setting CORS headers prevents hackers from scraping my API.
Truth: Hackers bypass browsers using python scripts, curl, or proxies. They ignore CORS headers completely. To protect your server, use proper authorization tokens and rate limiting. - Myth: Mobile apps require CORS.
Truth: iOS and Android native apps (built with Swift, Kotlin, or Flutter) do not run in a web browser sandbox and do not enforce CORS. (However, hybrid apps running inside web views like Cordova or Capacitor might).
How the Entire Flow Works
Here is a visual map of the entire preflight and API request lifecycle:
Browser Backend (localhost:8000)
| |
| --- [1] (Auto-intercepted fetch call) -------------> |
| OPTIONS /api/users |
| Origin: http://localhost:3000 |
| Access-Control-Request-Method: POST |
| |
| <-- [2] (Preflight Validation Response) ------------- |
| HTTP 204 No Content |
| Access-Control-Allow-Origin: localhost:3000 |
| Access-Control-Allow-Methods: POST, GET |
| |
| --- [3] (Send actual API call) ----------------------> |
| POST /api/users |
| Content-Type: application/json |
| |
| <-- [4] (Actual data returned) ----------------------- |
| HTTP 201 Created |
| {"success": true} |
| |
Conclusion
The next time you see an OPTIONS request in your browser's DevTools, don't worry about writing frontend logic to stop it. Remember:
- It is automatically sent by the browser to negotiate cross-origin access.
- It protects legacy servers from executing state-changing actions.
- It is triggered by JSON payloads (
application/json), credentials, or custom headers likeAuthorization. - You can optimize it away on subsequent calls by setting the
Access-Control-Max-Agecache header.
Understanding this flow is key to debugging network errors and building robust, cross-origin APIs that are both secure and high-performing.
Thanks for reading.