A new colleague who hasn't worked with single page applications (SPAs) yet was confused about how SPAs, other static assets and API calls are served by the infrastructure in a development as well as a production environment. This topic seems to be confusing to many people who are new to SPAs. For this reason, I decided to write this blog post I can refer to when this question comes up again.

The material is quite basic and no special prerequisites are required. The goal of the post is to foster a high level understanding of the infrastructure pieces and traffic routing rules that are involved when a SPA frontend is used. For simplicity, we will ignore load balancing and caching.

Terms and Concepts

Let's start by defining a few basic terms we will use later on.

Single Page Application (SPA)

In the past, websites were usually implemented as multi page applications. Interacting with a website caused a request for a new page to the server. The server returned the fully rendered HTML to the client. The usage of JavaScript was limited to a small number of Ajax calls and client side interactivity.

SPAs don't load entire new pages from the server after the inital page load. Instead, the page is dynamically rewritten by JavaScript. The JavaScript code includes all "templates" (commonly called components) that are necessary for rendering. This way, once the page is loaded, the code only needs to fetch the small amount of data necessary to fill out these templates instead of a whole bunch of HTML when navigating.

Reverse Proxy

A reverse proxy receives requests from clients and forwards these requests to other systems (e. g. web servers, application servers) according to user-specified rules. These rules could look like this: if the host HTTP header of a request is example.com, forward the request to web server A. If the host header is foobar.com, forward the request to web server B. Otherwise, return HTTP status code 404, page not found.

Traefik is an example for a modern reverse proxy. While Traefik is acting as reverse proxy only, other solutions such as Nginx combine web serving and reverse proxy capabilities.

Web Server

A web server serves files from a file system. For example, a web server could serve all files in the directory "/var/www". This directory might contain an index.html page which would be returned by the web server if the client requests the url "/index.html". Web servers often include reverse proxy capabilities. Well known web servers include Nginx and Apache HTTP Server.

Application Server

While a web server simply serves files from a file system, an application server has more sophisticated, custom business logic. For a SPA, the application server will implement a HTTP API which often is structured according to the principles of Representational State Transfer (REST). With multi page applications, the application server responds with rendered HTML.

Application servers usually communicate with other application servers, databases etc. to fulfill their tasks. In principle, an application server can also serve static resources like a web server does, but a web server is specialized for static serving and therefore performs better.

Cross-Origin Resource Sharing (CORS)

For security reasons, browsers by default disallow JavaScript code to make requests to origins other than the one it was served from. This is called Same-Origin-Policy (SOP). The origin consists of URI scheme, host and port. If any of these three items differ between two URLs, they are considered as separate origins by browsers.

Let's look at an example. Imagine the HTML file below is served on http://localhost:1234/index.html. We start a second server that listens on localhost:8081. When we navigate to http://localhost:1234/index.html in a browser, the browser executes the JavaScript included in the page. The JavaScript code then performs an Ajax request. Because of SOP, the Ajax request to the second origin is disallowed.

<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
  axios.get('http://127.0.0.1:8081/')
  .then(function (response) {
    console.log("Response succeeded", response);
  })
  .catch(err => {
    console.error("Request failed", err)
  })
</script>

CORS is a mechanism to relax SOP. The server receiving a request defines HTTP headers that specify which origins are allowed to communicate with it. The figure below illustrates this mechanism. If the last HTTP response from origin B has the CORS HTTP headers set such that requests from origin A are allowed, the JavaScript code receives the response. If the CORS headers don't allow requests from Origin A, the browser will report a network error to the JavaScript code.

Of course, the Ajax call can have side effects. For this reason, browsers execute a preflight request before executing the real request for some HTTP verbs. The preflight request uses the OPTION HTTP verb to check the CORS headers first. If the CORS headers allow requests from the current origin, the browser sends the original request.

Request Flow - Multi Page Application

Next, we will take a look at the request flow of a traditional multi page application.

The browser requests a page. The reverse proxy forwards the request to an application server that generates the response. The response is sent back to the reverse proxy and browser. The browser parses the HTML and requests assets (images, CSS, JS, etc.) that are referenced in the HTML. The reverse proxy passes along the requests to the web server and the responses to the browser.

This exact process happens every time the user navigates to a new page on this website. It's obvious requesting all the assets every time the user navigates to a different page causes a delay. Browsers solve this problem by caching assets, but each page navigation still causes the transfer of a complete HTML file over the internet, no matter how small the difference between these two pages actually is.

In case a resource is requested which does not exist, either the reverse proxy (if specified by rules), the application server or the web server returns HTTP Status code 404.

Request Flow - Single Page Application

We'll discuss how a SPA handles a request for a nonexistent page next.

When a SPA is used, requesting a nonexistent page causes the SPA to be loaded in the browser which then displays that the page wasn't found.

The browser requests the page and gets the response from the reverse proxy. The reverse proxy is configured to always return the SPA (index.html) if no other rule matches. The browser parses the index.html and requests all assets referenced including the JavaScript code for the SPA (bundle.js). Once the bundle is executed and the SPA is bootstrapped, the SPA will display a "page not found" message because the current route is unknown to the SPA.

We will continue with a successful page load next.

As before, the browser requests a page (/user/41256 in this example). The browser gets the index.html of the SPA, parses it and requests referenced assets including the JavaScript bundle of the SPA. Depending on the current URL, the JavaScript code might take further actions such as displaying a loading spinner while fetching data from an API. In this example, the SPA requests the data for the user with ID 41256. The reverse proxy is configured to route any requests beginning with /api to the backend server which returns the requested data. The backend itself most likely needs to fetch the requested data from a database. Once the response containing the user details is received, the SPA rerenders the view to present the data.

Differences between dev and prod Environments

The infrastructure responsibilities that need to be fulfilled do not differ between dev and prod. The only difference is which programs fullfil the responsibilities and how these programs are configured.

Usually, the reverse proxy and web server responsibilities are fullfiled by a development server that is integrated with the build tool (e. g. webpack). The dev server is configured to forward requests that start with a prefix (often "/api") to the application server.

While developing, the application server and dev server will often both be running on the development machine. Proxying these requests via the dev server instead of sending requests from the browser to the backend directly is necessary because of CORS.