Back in May, my fellow boomerang developer Nic Jansma explained 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
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.
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 Wal 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.”
Now early on, browser developers realized 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.
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 personalized 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.
[RELATED: Read the "How-To: Add Timing-Allow-Origin Headers to Improve Site Performance Measurement" blog post.]
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
text/plain, e.g. if the POST request sends an XML payload to the server using
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-WithHTTP header tends to trigger this.
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 the 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?
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-Withis 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.
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
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.
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, and therefore should be part of the cache key, both in the browser, as well as in intermediate proxies.
(RELATED: Read the "Improve API Performance with Caching" blog post.]
XMLHttpRequestswork 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
Acceptfor content negotiated responses instead.
- Use the
Varyheader to tell clients and intermediates that the
Acceptheader is important for caching.
- It’s important to learn from history so that we do not repeat old mistakes.
- YUI does not add the
X-Requested-Withheader when making XHRs.
- JQuery does not add the
X-Requested-Withheader for cross-domain XHRs
- Dojo does add the
X-Requested-Withheader for all XHRs, and you need to explicitly clear it
- As of version 1.1.1 (2012), AngularJS no longer adds the
- Prototype.js does add the
X-Requested-Withheader along with other custom headers for XHRs and you need to explicitly clear it.