Solving the OPTIONS Performance Issue With Single Page Apps

2015/12/14 posted in  Node.js

@see https://dzone.com/articles/solving-the-options-performance-issue-with-single

Single-page applications are all the rage these days. VariousnewJavaScriptframeworks make it very easy to write complex applications in JavaScript, with most of the MVC pattern running in the browser. This of course comes with its own challenges, and at SOASTA, we’re always up for those.

Back in May, my fellow boomerang developer Nic Jansmaexplained how we’ve hacked boomerang to measure the performance of single-page applications. Today, let’s talk about another issue: the performance of SPAs when CORS comes into play.

Matt Aimonetti, co-founder of Splice, tech entrepreneur, and regular conference speaker recently ran into this issue, and has agreed to co-author this post, sharing his experience.

But First, Some History…
Way back in the late 1990s, Microsoft introduced the XMLHttpRequest object to make background HTTP requests from JavaScript without disrupting the flow of a page; however, because no other browser (aka Netscape) supported it, it went largely unnoticed.

On April 1, 2004, as part of an elaborate April Fool’s joke that’s ongoing to this day, Google released what by many is considered the first widely known single-page app (we called them Rich Internet Applications back then). They called it Gmail, and web developers everywhere started looking through the code to find out how they did it.

Pub Standards, Dec 2005
In 2005, Jesse James Garrett coined the term AJAX to describe the communications method used by these apps, and standardistas everywhere decided to create best practices to avoid falling down the rabbit hole of unmaintainable code that failed accessibility standards we’d worked so hard to create. Or as Thomas Vander Waal put it:

“It must degrade well. It must still be accessible. It must be usable. If not, it is a cool useless piece of rubbish for some or many people.”
With that, Jeremy Keith introduced us to Hijax.

Now early on, browser developers realised they couldn’t just allow you to make XHR calls anywhere, because that would allow attackers to steal third-party information in the background relying on a user’s logged in cookies, so XHR was limited to the same-origin policy, i.e., you could only make an XHR call to a server that was on the same domain as the page you were making the call from. You could change document.domain to make this domain check slightly less restrictive, but it was still limited to a parent domain of the current page.

Enter CORS

This security model kinda worked, but we were also entering the age of Web APIs, where third parties wanted random websites to be able to pull data from their servers, potentially using the user’s logged in cookies to get personalised information. But more importantly, websites wanted to be able to use their own APIs that were potentially served from a different domain (like their CDN). Things like dynamic script nodes and JSON-P worked, but they broke the security model, making it harder to protect these services from Cross-Site Request Forgeries.

The web standards group stepped in to introduce the concept of Cross Origin Resource Sharing, or CORS, which states that a server can specify via the Access-Control-Allow-Origin header, which Origins its content is allowed to be shared with.

Preflighted Requests

Unfortunately, every cool specification also comes with unexpected security considerations. For CORS, this is a preflighted request.

According to MDN,

In particular, a request is preflighted if:

It uses methods other than GET, HEAD or POST. Also, if POST is used to send request data with a Content-Type other than application/x-www-form-urlencoded, multipart/form-data, or text/plain, e.g. if the POST request sends an XML payload to the server using application/xml or text/xml, then the request is preflighted.
It sets custom headers in the request (e.g. the request uses a header such as X-PINGOTHER)
The idea is to ask the server for permission to send custom headers, and as others have found, the commonly used X-Requested-With HTTP header tends to trigger this.

Flowchart for Preflighted XHR

At Splice, we have a Go backend and we started out with a Rails frontend talking to our APIs. As time went by, the amount of JQuery code started to be hard to maintain and Rails rendering was becoming a bottleneck. We ported the frontend from Rails to Angular and everything seemed to be fine… until we started hearing complaints from our non-US users saying that parts of the website were slow for them. It turned out that these were the parts where we made many API calls to access user specific/signed/encrypted resources. Inspecting the network calls via a VPN connection, we noticed that main issue was latency.

The latency between Sweden and California isn’t great, but what’s worse was that each API call had to wait on the preflight OPTIONS call before dispatching the actual request. Our API’s response time is fast (sub 10ms), yet some users would see response times north of 500ms!

This second HTTP OPTIONS request, doubles the latency for getting your data… and Real Users™ hate latency.

So, how do we get rid of this extra request?

X-Requested-With

To get to that, we need to understand why developers use the X-Requested-With header, which brings us all the way back to 2005 with all those best practices around Ajax. In order to use canonical URIs for all resources, we use the same URL for the full page request as well as the XHR request, and use the X-Requested-With header to distinguish between the requester.

There are a couple of problems with this, though:

X-Requested-With is a custom header hoping to be a de-facto standard
We’re sending different responses for the same URL based on the type of requester rather than its advertised capabilities or the requested response format.
This starts to smell a lot like the late ‘90s, when we served different HTML to Internet Explorer and Netscape.

The solution for these problems is to use standard headers that correctly advertise capabilities or requested response format, and it turns out that the HTTP spec does have a standard header for just this purpose.

Other Custom Headers
In recent versions of Angular and JQuery, this header was actually removed by default unless explicitly added, so it wasn’t an issue for Splice, but because we used to send this header (via JQuery), we wrongly assumed that preflight requests was unavoidable.

We did, however have other custom headers that we used to report the version (git hash) of the JavaScript code making the request. These headers had the same effect, since they failed the custom headers check for the preflight.

The Accept Header

The Accept header, described in section 14.1 of RFC 2616, allows the caller to specify the content types it is willing to receive. By default, the browser will include text/html as the preferred acceptable type. When making our XHR request, we could specify application/json, application/xml, text/csv or anything else as the only acceptable content type.

Your server application can then look at the Accept header to decide whether to respond with the full HTML or with a JSON representation of the data or something else. The technical term for this is called content negotiation.

Other implementations add a query string parameter to the URL specifying the requested content type or other parameters related to the client library like a version or a hash.

Caching

Responses to these requests can be cached if they have the appropriate cache-control headers. It’s important to make sure that the server uses the Vary header to specify that the Accept header was used to generate negotiated content content, and therefore should be part of the cache key, both in the browser, as well as in intermediate proxies.

Summary

Cross-domain XMLHttpRequests work if the server supports the appropriate CORS headers.
Adding custom headers or using a non-standard content-type forces the browser to issue a preflight OPTIONS request to determine if these are acceptable or not, and this effectively doubles the latency of fetching data.
Avoid custom HTTP headers, and use standard headers like Accept for content negotiated responses instead.
Use the Vary header to tell clients and intermediates that the Accept header is important for caching.
It’s important to learn from history so that we do not repeat old mistakes.
Notes
YUI does not add the X-Requested-With header when making XHRs.
JQuery does not add the X-Requested-With header for cross-domain XHRs
Dojo does add the X-Requested-With header for all XHRs, and you need to explicitly clear it
As of version 1.1.1 (2012), AngularJS no longer adds the X-Requested-With and X-XSFR-TOKEN.
Prototype.js does add the X-Requested-With header along with other custom headers for XHRs and you need to explicitly clear it.

References