In modern web applications, it’s a common pattern to serve the web API and the frontend app from different subdomains:
https://api.example.com
: your web API, usually serving JSON documentshttps://example.com
: your web application, usually built in JavaScript, generating HTML documents from the raw JSON data retrieved from the API
This was the pattern implemented by API Platform until last year. But we changed that for 2 main reasons: performance and REST principles.
CORS Hurts Performance
Let’s talk about performance first. When serving your API from a different origin than the frontend application, browsers will automatically send an additional OPTIONS
request before any request is made to the API. This is called a preflight request, which is necessary because of CORS (Cross-Origin Resource Sharing). These preflight requests ensure that the frontend app is allowed to interact with the resources served by the API.
As Nick Olinger explains in a recent post, these preflight requests add unnecessary latency, slow down applications, waste battery on mobile devices, and increase servers load.
The preflight requests problem is even more severe when implementing API styles leveraging the multiplexing capabilities of HTTP/2 and HTTP/3, such as Vulcain.rocks. The main idea behind Vulcain-like approaches is to download small resources in parallel, only when needed, and with a good cache hit rate. These approaches are alternatives to downloading large compound documents as in GraphQL or JSON:API. But if – because of this preflight requests problem – the number of requests made is multiplied by 2, the latency will be bad and the benefits of Vulcain-like approaches will be undermined.
The solution to prevent these preflight requests is simple: serve the API and the frontend application from the same origin! And it’s what we’ve done in API Platform 2.6.
In his article, Nick proposes a quick workaround: configure your reverse proxy to alias your API as a path under the same origin as your web app. https://api.example.com
becomes https://example.com/api
, all resources belong to the same origin, problem fixed! But we can do even better by following the REST principles.
Same Resource, Different Representations
The architectural model of the web is known as REST (Representational State Transfer). This style has been defined by Roy Fielding while working on the core standards that are still powering the web today: URI (Uniform Resource Identifier) and HTTP (Hypertext Transfer Protocol):
Basically, a good web application should be following the REST principles, as REST is the architectural style of the web itself.
In his doctoral dissertation, Fielding defines the concepts of “resource” and “resource representation“. A resource is an abstraction of information. It can be “any information that can be named”. On the web, a resource is identified by a URI (aka URL). A resource representation is a concretion, it’s “a sequence of bytes, plus representation metadata to describe those bytes”. Usually, a resource representation is known as a “file” or a “document”. This means that the same resource, having the same identifier (its URI or URL) can have different representations.
As an example is often worth a thousand words, let’s imagine a blog platform software. This software exposes blog posts. Posts are available through a web API, to be retrieved by machines. They are also available on a website, as human-readable articles. Under the hood, the website queries the web API to fetch the raw post data as JSON and transforms it into a human-readable HTML document.
If we think in terms of REST concepts, we have one resource (the abstraction): the blog post. This resource has a unique identifier: its URI, for instance https://example.com/blog/about-preflight-requests
. Finally, this resource has two representations:
- the JSON document, served by the API: the machine-readable representation of the resource
- the HTML document, served by the website: human-readable representation of the same resource
This sounds good, but if both representations have the same identifier, how can the server knows the one it must return? Here comes content negotiation.
REST, as well as the HTTP protocol, define mechanisms allowing the server to send the appropriate resource representation. Using the “proactive negotiation” mechanism, the client can inform the server that it prefers a JSON representation of the resource, by using the Accept
request header:
GET /blog/about-preflight-requests HTTP/1.1
Host: example.com
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
{
"title": "About Preflight Requests",
"...": "..."
}
Correspondingly, a web browser can use the Accept
header to request an HTML representation:
GET /blog/about-preflight-requests HTTP/1.1
Host: example.com
Accept: text/html
HTTP/1.1 200 OK
Content-Type: text/html
<!DOCTYPE html>
<title>About Preflight Requests</title>
<!-- yes, this is a valid HTML document! -->
We now have an elegant way to eliminate the CORS preflight requests! By checking the REST principles, we identified that we weren’t serving two different resources, but two representations of the same one. Content negotiation enables the server to send the appropriate resource representation depending on the client: our JavaScript app will get the JSON representation while the web browser will get the HTML one. Because all resources are now served from the same origin (and even have the same URL), we don’t need preflight requests anymore.
However, these days, web APIs and websites are usually developed using different technologies (e.g. Go, Rust, or PHP for the API, JavaScript for the website). And it’s why it’s convenient to serve them from different subdomains. Are we going in circles? Fortunately, modern web servers and reverse proxies make it easy to route requests across different backends according to content negotiation headers.
Routing Requests with the Caddy Web Server
As you may know, I like the Caddy web server a lot! If you don’t know Caddy, it’s a modern, fully-featured and open source replacement for Apache and NGINX. It is written in Go. Used as a reverse proxy, Caddy makes it easy to inspect the request and to route it to the web API or to the website depending on the Accept
header. Here is a simplified version of the Caddyfile
provided by API Platform 2.6 to achieve this:
# Matches requests for HTML documents
@pwa expression `{header.Accept}.matches("\\btext/html\\b")`
reverse_proxy @pwa http://front
reverse_proxy http://api
First, a request matcher is defined. If the Accept
header contains the text/html
MIME type, the request is routed to the JavaScript application (http://front
), otherwise, the request is forwarded to the API (http://api
). These are internal URLs. The public URL is the same for both representations, Caddy does the dispatching. And voilà! no more preflight requests, and a clean design respecting the REST principles!
The API Platform repository contains a more advanced example. Of course, it’s also possible to achieve this using other reverse proxies.
This has been quite educational. Well done… 🙌🏾 I’ve learnt a lot.