Techniques for bypassing CORS Preflight Requests to improve performance<!-- --> | <!-- -->Web Performance Tips

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).

A diagram showing CORS Requests across the network between a web application and a remote backend of different origin.

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:

A diagram showing CORS Requests cache utilization.

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:

  1. Request resource https://api.my-service.com/sync?syncToken=abc
  2. This incurs a preflight OPTIONS, which is subsequently cached because it specified Access-Control-Max-Age.
  3. GET proceeds for https://api.my-service.com/sync?syncToken=abc
  4. Request another resource https://api.my-service.com/sync?syncToken=def (syncToken is different between the two requests)
  5. 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:

A diagram showing the iframe of same origin dispatching requests to the backend.

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:

  1. From example.com process to the <iframe> process
  2. From the <iframe> process to the Network process
  3. From the Network process back to the <iframe> process
  4. Finally, from the <iframe> process back to the example.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:

A diagram showing a web application bypass the CORS request by proxying through an endpoint of similar origin.

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:

A state diagram outlining the CORS logic

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.