CORS, you so slow!
Cross-Origin Resource Sharing (CORS) is a mechanism that uses additional HTTP headers to tell a browser to let a web application running at one origin (domain) have permission to access selected resources from a server at a different origin. A web application makes a cross-origin HTTP request when it requests a resource that has a different origin (domain, protocol, and port) than its own origin. — https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
CORS in general looks like a useful tool until you see the performance impact. At Glia, our public REST API (API from now on) and our Web Client App (APP from now on) live in different subdomains. Sending a request from the APP to the API will cause a preflight (OPTIONS) request. This operation on its own is relatively cheap, the OPTIONS request is sent with only a few headers and the server responds with information about what is permitted.
Press enter or click to view image in full size
In a development environment where everything is running on the same machine, this request is almost instant, but the story is very different in the wild world. The picture on the left shows timings of a preflight request that was sent from an Eastern European location to a server which was located in the Western US. This preflight request added almost an extra half of a second delay. Such delays are very noticeable to us and to our clients.
Even though browsers have mechanisms to reduce the performance impact they all fall short in most of the real use cases:
Use caching?
CORS caching is very restrictive:
- Chrome sets an upper limit for preflight request cache expiration which is 10 minutes. Firefox has it set to 1 day.
- Many RESTful URLs include some kind of resource ID in the URL. Caching
DELETE /articles/1still means that the browser has to do a new preflight request when invokingDELETE /articles/2.
Send only simple requests?
CORS specification says that preflight requests are not mandatory for Simple Requests. A request is considered simple if it follows these rules:
- The HTTP method is
HEAD,GETorPOST. - Only
Accept,Accept-Language,Content-LanguageandContent-Typeheaders are used. - Content-Type header is set to
application/x-www-form-urlencoded,multipart/form-dataortext/plain
In our case, most of our APIs expect JSON payloads. We also use other HTTP methods like PUT, PATCH and DELETE. We could make it work by sending _method attribute to route the requests and by using text/plain content type, despite the contents being in JSON format. However, we are committed to having clean APIs so this is not an option for us.
Avoiding CORS Requests
As usual in the world of web development, there are ways to work around the restrictions imposed by browsers.
document.domain
This solution works only when both the site and the server share the same base domain. Lets say you have a public API on api.example.com and a user-facing site (the APP) on app.example.com. Making a request from the APP to the API will trigger a preflight request because the subdomains are different.
However, we can avoid the preflight request with a simple iframe. First we need to expose a new endpoint in our API that returns the following HTML.
<!-- https://api.example.com/app-proxy.html -->
<!DOCTYPE HTML>
<html>
<head>
<script>
document.domain = 'example.com';
window.parent.fetch = window.fetch.bind(window);
</script>
</head>
</html>There are two important things to notice here:
- First we set
document.domain = 'example.com'. Browsers allow changing the current document.domain to a super-domain. E.g. It is possible to changefoo.example.comtoexample.com, but it is not possible to change it tobar.example.comnorsomething.else.com. - We override the parent window
fetchfunction with the current windowfetchfunction.
Now in the APP we can add following two lines of code:
<script>document.domain = ‘example.com’</script>
<iframe src=”https://api.example.com/app-proxy.html"></iframe>First we change the current document domain to example.com. Now both API and APP are using the same origin. This allows us to execute JavaScript between the frames. The second line includes the page we exposed in the API as an iframe. This overrides the current window fetch function with the fetch function that was loaded from the API domain.
Now we can use Fetch API as usual to make requests to the API without
any preflight requests.
Get Indrek Juhkam’s stories in your inbox
Join Medium for free to get updates from this writer.
Benefits:
- Transparent. The code using
fetchdoes not need to know about thedocument.domainchanges. The site will do regular requests with preflight requests when loading the iframe fails. - Cacheable. The proxy HTML doesn’t need to change except when we want to change the domain which should be a very rare occurrence. We can serve it from the API server with a long cache or even upload it to a CDN.
- Low effort. All it entails is exposing one static page and adding two lines of code to the user-facing site.
Constraints:
- The sites can have different subdomains but they have to have the same base domain.
- Includes an iframe
- This works only with one API domain. Overwriting
fetchis not an option when having more than one API domain. We can however expose them as different fetch functions (e.g.window.api1Fetchandwindow.api2Fetch).
postMessage
postMessage approach uses iframes like document.domain solution but it
allows having sites on completely different domains.
For this we need to expose an endpoint in the API which serves an HTML page — the proxy. We can then use Window.postMessage() to send messages between the APP and the proxy page, which then sends them to the API without preflight requests.
Benefits:
- Supports different domains. It doesn’t matter where the API is located. The entire domain can be different.
Constraints:
- Includes an iframe
- Needs a custom adapter for making requests. With
document.domainmethod we were able to continue to use the Fetch API exactly as before. Here we need a custom adapter to facilitate the communication between the frames and ensure everything works even if loading the iframe fails.
Check out the xdomain library when going with this approach. It currently doesn’t support the Fetch API but regular XHR requests should be fine.
Reverse Proxy
This solution works by exposing the API on the current domain. E.g. If you have API served on api.example.com and your APP is on app.example.com then you can set up a reverse proxy on app.example.com/api that proxies all requests to api.example.com.
Benefits:
- No iframes.
- Supports different domains. It doesn’t matter where the API is located. The entire domain can be different.
Constraints:
- Needs a reverse proxy. If there already is a web server (e.g. NGINX) that is serving the APP content then this can be trivial.
- Cannot be used when the user-facing site is not owned by us. E.g. in Glia we provide a JavaScript API to our clients. It’s not reasonable to force each of our clients to set up a reverse proxy like
world-largest-bank.com/glia-apijust so that we could avoid cross domain requests from our JS API. - It changes the URLs which may be annoying.
WebSocket Proxy
This is the most complex solution. It can be a useful approach when other approaches are not possible or when users are already connected to an existing WebSocket server.
For this we need to set up a web server that accepts WebSocket connections. The server then translates WebSocket messages to a HTTP requests and executes them. The responses are then translated back to WebSocket messages and are sent back to the user.
Benefits:
- No iframes.
- Supports different domains.
Constraints:
- Needs a WebSocket connection.
- Complex. Needs a server which knows how to translate WebSocket messages to HTTP requests.
- Security concerns. All proxied requests are now using the WebSocket Proxy server IP. This can end up badly when the WebSocket Proxy server does not whitelist allowed domains, do rate-limiting, etc.
Conclusion
CORS can have a high performance impact for sites that do a lot of cross-origin requests. Browsers have features like CORS caching, but these are not very efficient. The best option is to avoid preflight requests altogether whenever possible.