Techniques for bypassing CORS Preflight Requests to improve performance
CORS (Cross Origin Resource Sharing) enables web apps to communicate securely across origins.
It typically functions by having the browser initiate a Preflight request (with the OPTIONS HTTP method) to the target origin. The target server replies with information telling the requestor whether or not their origin is allowed to call the service for the actual resource.
This Preflight request is dispatched transparently by the browser -- JavaScript code does not explicitly enact the Preflight OPTIONS request.
If the target server accepts the OPTIONS request and notifies the web application that it's allowed to securely call it, the web application can proceed with dispatching the actual HTTP request, whether it's a GET, POST, or whatever it originally wanted to communicate with the target backend for.
For example, if your web application hosted on https://www.example.com
requests data from https://api.my-service.com
, these
two endpoints are hosted on two separate origins. As a result, they will utilize CORS Preflight Requests to communicate securely
(unless the requests are simple requests, discussed shortly).
From a performance standpoint, this means that each HTTP communication with a backend of a different origin may require two roundtrips: one for the OPTIONS request, and one for the actual HTTP method (GET, POST, etc.). This is known as a serial / pipelined network call pattern.
On unreliable or slow networks, this can manifest as a performance bottleneck when retrieving remote resources is in the critical path for presenting your next frame to your users.
Fortunately, there are techniques to bypass CORS, which we'll discuss next!
Strategy 1: Caching
One mechanism you can use to ensure repeat CORS Preflight requests aren't a bottleneck is to apply a Access-Control-Max-Age
header
to the response from the backend.
For example, we can configure the backend with the following header for the OPTIONS request would instruct the browser to cache the result of the OPTIONS request for 24 hours (time is specified in seconds):
Access-Control-Max-Age: 86400
This will ensure repeat requests for the same method, origin, and path will be able to bypass the initial OPTIONS round-trip:
Caching Caveats
While caching is a great and straightforward strategy to help optimize CORS Preflight request overhead, there are a few caveats with this approach one should be aware of.
Browser-Enforced Max Cache Duration
Each browser has an upper bound on how long the length of a Access-Control-Max-Age
header is acceptable for.
Notably, Firefox will cap the max age to 1 day, and Chromium will cap the max age to 2 hours.
As a result, specifying a large cache age value may not produce as many cache hits as you would expect!
Cache Keys
The browser keys its cache by HTTP Method and URL. As a result, if you are accessing a cross-origin backend, and make multiple API calls with variable sets of query string parameters, then caching will have little-to-no impact on performance.
Consider the following example:
- Request resource
https://api.my-service.com/sync?syncToken=abc
- This incurs a preflight OPTIONS, which is subsequently cached because it specified
Access-Control-Max-Age
. - GET proceeds for
https://api.my-service.com/sync?syncToken=abc
- Request another resource
https://api.my-service.com/sync?syncToken=def
(syncToken
is different between the two requests) - This cannot use the previously cached values, because the query string parameters are different. Another OPTIONS Preflight request is dispatched.
Strategy 2: Iframe of Same Origin
CORS is only enforced by the browser when the requestor resides on a different origin than the target backend.
With this technique, the request dispatch is done through an <iframe>
element, which resides in the same
origin as the target backend. It does require the backend to provide an HTML document that can dispatch
requests, though.
Because the request dispatch is done through a document of the same origin as the backend, the browser does not need to utilize CORS Preflight requests.
Consider the following code snippets.
Here's an example section of the document for www.example.com
:
<iframe id="request-dispatch" src="https://api.my-service.com/request-dispatch.html"></iframe>
<script type="text/javascript">
// Subscribe to replies from the <iframe>
window.addEventListener('message', event => {
// Setup validation and security checks
if (!isValidAndFromIframe(event)) {
return;
}
// Resolve a Promise, Observable, etc. with the
// new data.
notifyAppOfReply(event.data);
});
function dispatchRequest(requestInfo) {
const frame = document.getElementById('request-dispatch');
// Post requestInfo to the target iframe
frame.contentWindow.postMessage(requestInfo);
}
// Other app code...
</script>
In the document within the <iframe>
, it has the following setup:
<!-- https://api.my-service.com/request-dispatch.html -->
<script type="text/javascript">
// Subscribe to postMessage events
window.addEventListener('message', (event) => {
// Do some validation on the incoming message
if (!isValid(event)) {
return;
}
// Make the request to the backend. There will be no CORS request
// because this resides in the same origin as the service.
fetch(event.data).then(response => response.json()).then(jsonResponse => {
event.source.postMessage(jsonResponse, event.origin);
});
});
</script>
This will produce the following behavior:
Iframe Caveats
Using the <iframe>
option does work, but it adds some complexity and overhead.
Serving the Iframe
In terms of complexity, the API backend (api.my-service.com
in this example) would need to support a separate
endpoint for serving the HTML document to load in the iframe.
If you don't have full control over the target backend, this may prove complex or infeasible.
Loading the Iframe
With the <iframe>
being the mechanism to dispatch requests to the target API backend, this would prevent the
application in www.example.com
from requesting any data until the Iframe was fully loaded.
This means that delivery of the Iframe itself to the client could become a bottleneck.
If this technique is utilized, one should consider aggressive HTTP Caching of this resource, and potentially moving it to a CDN or Network Edge.
Browser Overhead
Browsers may create a dedicated process for hosting the <iframe>
. This could consume more
system resources than necessary. It also incurs overhead in machines that are under heavy load, and creating the process
to host this <iframe>
could become a bottleneck.
Furthermore, messaging from the root-level document to the <iframe>
creates another layer of inter-process communication,
as messages must be dispatched across multiple processes:
- From
example.com
process to the<iframe>
process - From the
<iframe>
process to the Network process - From the Network process back to the
<iframe>
process - Finally, from the
<iframe>
process back to theexample.com
process
Depending on the load of the client's system, this overhead could end up being higher than the cost of CORS. Make sure to measure appropriately!
Single vs. Multiple Requests
If your web application makes a single call to the cross-origin backend, the benefits of this technique diminish due to complexity and the overhead described above.
This technique may work better if you have multiple requests in a session to the target backend, because you'd pay a
one-time setup cost on initializing the <iframe>
, but you would save in bypassing CORS for each subsequent request.
Strategy 3: Setup a Proxy
This technique is similar to the <iframe>
strategy in that it aligns the origins of the requestor and the target
backend, thereby bypassing the requirement for CORS.
Unlike the <iframe>
approach, it utilizes an HTTP Proxy to mask the fact that the target backend is actually
behind another origin. This proxy is hosted to appear as the same origin as the requestor:
There are a plethora of ways to setup a proxy. Some include Cloud Service Providers, such as Azure Front Door or AWS CloudFront.
Alternatively, you could proxy requests directly through the backend hosting www.example.com
. Furthermore, if you are
the owners of the target backend, it might be easier to simply configure your API service's DNS records to align on the same
origin as your client web application.
Proxy Caveats
While proxying requests through the same origin does bypass the CORS requirements, it does force an additional network hop while traveling to your target service.
As a result, you should always measure your application's performance and ensure the change is properly impacting your end-user's perceived performance.
Strategy 4: Refactor to Simple Requests
As per the CORS specification, there are types of requests that do not trigger a CORS preflight request. These requests are called simple requests.
Consider the following state diagram:
Simple Request Challenges
While simple requests do allow the browser to bypass CORS Preflight OPTIONS calls, composing a useful simple request can be challenging, considering how strict they are to formulate.
One restriction that often becomes an issue is that simple requests don't allow for custom HTTP headers. This is typically problematic
for authorized API calls that use the Authorization: Bearer <Token>
pattern.
If your call pattern requires custom properties, such as auth tokens, custom metadata headers, etc. you may need to refactor your backend or gateway to accept alternate request formats, described next.
Simple Request Example
Consider the following request, issued from https://www.example.com
:
GET https://api.my-service.com/api/items
Headers:
Content-Type: application/json; charset=utf-8
Authorization: Bearer <TOKEN>
X-Custom-Metadata: CustomHeader
Because there are custom headers Authorization
and X-Custom-Metadata
, the browser must issue a preflight CORS request, because
these custom headers preclude the browser from considering this as a simple request. (Note, Content-Type
is not considered "custom"
and is therefore compatible with a simple request).
POST Body Refactoring
One strategy to include custom metadata while conforming to being a simple request is to include custom metadata in a POST body, rather than in HTTP Headers.
For example, let's refactor the API call from the client into a POST request as such:
POST https://api.my-service.com/api/items
Headers:
Content-Type: application/json; charset=utf-8
Body:
{
"actualMethod": "GET",
"authorization": "Bearer <TOKEN>",
"customMetadata": "CustomHeader"
}
In this sample HTTP request, there are no custom headers, and as a result, the browser considers this request a simple request.
Complexity arises in the fact that a POST request is specifying that it will be functioning as a GET when it reaches the
target backend, through the actualMethod
property. A backend or gateway must be authored to understand this custom syntax, and it
increases complexity to do so. Furthermore, it adds a layer of indirection in semantic REST API verbs of GET, PUT, POST, and DELETE
from a client's perspective.
Query String Refactoring
Another option is to use query string parameters, such as the following:
GET https://api.my-service.com/api/items?customMetadata=CustomHeader
Headers:
Content-Type: application/json; charset=utf-8
In this sample HTTP request, we can deliver the customMetadata
information to the backend via a query string parameter instead
of using custom headers. This request would also be a valid simple request and bypass the Preflight OPTIONS requirements.
This is less complex than the POST body mechanism described above, but, I generally don't advise this for authorized requests that utilize a JWT access token for security purposes.
Conclusion
We've outlined several techniques for removing the serial / pipelined network call for cross-origin API calls from the client.
If you chose to integrate any of these strategies in your application, make sure to measure to ensure your optimization is effective for end users!
That's all for this tip! Thanks for reading! Discover more similar tips matching Network.