In Devforth we love to simplify and automate everything that could be automated. Especially when it comes to software development tools which we use every day in our professional journey. Simplification of configuration eliminates tedious work and allows to turn development process into enjoyable and creative time.

In this post we will explain how to serve Single Page Applications without pain with a best performance. This will be done with our free and open-source server called Spa-to-http which we optimized specially for SPAs.

Motivation and story

Some time ago we started intensively use Traefik HTTP proxy instead of Nginx. Previously Nginx was our favorite all-in-one tool for HTTP traffic routing and SPA serving at the same time.

After we discovered how beautiful Traefik is in terms of simplicity of installation and maintenance we smoothly ported a lot of existing projects to it. Traefik even does not require a mandatory config to setup a routing between multiple upstreams in Docker Compose, you just use docker labels on containers to define a route rules.

Nginx vs Traefik brief comparison
Nginx vs Traefik brief comparison

You can also read comprehensive Nginx vs Traefik comparation.

One thing that seamed to be a stopper was a fact that Traefik does not support static files serving out of the box. There are a lot of options to workaround it, most obvious used by many developers in community is placing Nginx after Traefik only to serve static.

So we decided why not to write our own lightweight Go-based image, dedicated for SPAs, absolutely simply configurable, compatible with most common setups. Most challenging part was a performance, everyone knows that Nginx is "ultra fast server", so even implementing same performance at first sight was not so simple. But we managed to make spa-to-http even faster the Nginx at some points, benchmarks described at the end of post. We open-sourced spa-to-http under MIT license so you can use it in any commercial project.

Disclaimer: Politics explanation about russia
During couple of last months, russian bastards ruthlessly killed several our good friends from Ukraine using their f*ing missiles. Our friends were really good guys, they were "out of politics", also missile fragments wounded their children. We have nothing personal against Igor Sysoev, he is probably a good guy too, and struggled a lot from russian corruption, but anyway any positive mentions of "russia" word might indirectly lead to economical profits of country who makes a missiles and directs them to civils.

Spa-to-http vs Nginx for serving SPAs

Here is a brief comprasion

Spa-to-httpNginx
Zero-configuration✅No config files, SPA serving works out of the box with most optimal settings❌Need to create a dedicated config file
Ability to config settings like host, port, compression using Environment variables or CLI✅Yes❌No, only text config file
Docker image size13.2 MiB (v1.0.3)142 MiB (v1.23.1)
Brotli compression out-of-the-box✅Yes, just set env BROTLI=true❌You need a dedicated module like ngx_brotli

What a most developers care about is serving performance, and we spent some extra-effort to optimize it too, here I will share results of benchmark performed at the bottom of post:

Spa-to-httpNginx
Average time from container start to HTTP port availability (100 startups)✅1.358 (11.5% faster)❌1.514
Requests-per-second on 0.5 KiB HTML file at localhost *✅80497 (1.6% faster)❌79214
Transfer speed on 0.5 KiB HTML file *❌74.16 MiB/sec✅75.09 MiB/sec (1.3% faster)
Requests-per-second on 5 KiB JS file at localhost *✅66126 (5.2% faster)❌62831
Transfer speed on 5 KiB HTML file *✅301.32 MiB/sec (4.5% faster)❌288.4

* wrk test results performed during 60 seconds

Example using spa-to-http 

In example we will show how to deploy Vue/Svelte/React app using Vite bundler, but you can easily adopt it for any SPA Framework like Angular even if it is based on Webpack or CRA.

First, let's create a folder where we will work:

mkdir serve-spa
cd serve-spa

Now let's scaffold Vite-based project:

npm create vite@latest

You will be asked to type in project name, you can just use frontend. Then you can select any SPA framework you like with up/down arrows:

Selecting Vue/React/Svelte project with Vite
Selecting Vue/React/Svelte project with Vite

Now let's install modules:

cd frontend/
npm install

You can also temporarily start a development hot-reload server:

npm run dev

Now you should be able to open an app in browser and can play a little bit with adjusting it. You are now in development mode.

To build SPA into production-ready set of static files you should use:

npm run build

It will create a dist folder with a final bundle suitable for production serving. We will run this command in Docker at build time using correct layers caching.

Just create a Dockerfile in a frontend folder:

FROM node:16-alpine as builder
WORKDIR /code/
ADD package-lock.json .
ADD package.json .
RUN npm ci
ADD . .
RUN npm run build

FROM devforth/spa-to-http:latest
COPY --from=builder /code/dist/ .

At the beginning we specify that we need to pull lightweight alpine version of node image from Dockerhub to build our frontend. Then we add a package-lock and package files which are used by npm ci command. Only after that we are adding rest of source files and run build. After build done we pull another image which is our spa-to-http web server and extract dist folder generated on previous step.

⚠️ If you are not familiar with caching  of Docker layers, we should emphasize that order of these commands in Dockerfile determines performance of your builds. Changes in package/lock are very rare because you are not installing/changing node modules every build. That is why we place them and npm ci command closer to the beginning of Dockerfile, so in majority of builds these files will not be changed and relatively slow npm ci command will not be re-executed.
🤔 Exciting fact, but a lot of pretty experienced coders whom I asked, thought that npm ci is acronym for Node Package Manager Continuous Integration. Well, you are pretty smart if you found this analogy by your guess too, but you would be smarter if you just Google it. It means Clean Install and nothing more. This command just completely removes node_modules folder (if it exists) and recreates it from repositories.

Now you can try to build image using:

docker build .

If build is successful then you can spawn container from image by running:

docker run --rm -p 8080:8080 $(docker build -q .)

After that you can go to http://127.0.0.1:8080 and test SPA. It is already bundled in production more.

Traefik + compose example to route traffic into spa-to-http container

If you need to run other services like API on the same host you need route traffic using reverse proxy. Also you might use same proxy for example to accept SSL traffic to support and enforce https protocol. Here we will show how to do it with Traefik. Return to serve-spa folder and create a docker-compose.yml file there:

version: "3.3"

services:

  traefik:
    image: "traefik:v2.7"
    command:
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
ports: - "80:80" volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" spa-to-http: build: "frontend" labels: - "traefik.enable=true" - "traefik.http.routers.spa-to-http.rule=Host(`serve-spa.localhost`)" - "traefik.http.services.spa-to-http.loadbalancer.server.port=8080" # port inside of spa-to-http which should be used

And start containers from our stack:

docker compose up -d

Now you can go to http://serve-spa.localhost/ and you should see your production ready SPA. 

In same way you can add an API service as a container and route /api to it using label:

- "traefik.http.routers.my-api.rule=Host(`serve-spa.localhost`) && PathPrefix(`/api`)"

This container could be NodeJS Express, Python FastAPI, DjangoRestFramework or any technology you like to build your APIs.

Beautifully simple setup, is not it? We were able to set-up HTTP proxy routing only by one compose file🎉 If we would use pure Nginx instead of Traefik we would have to do a lot of extra things:

  1. Create and manage another config file(s) for Nginx
  2. We would have to pass this config file(s) by attaching via volume or create a custom image inherited from Nginx image from Dockerhub
  3. We would have to deliver our dist folder via volume from frontend image into Nginx image. Now this folder remains in spa-to-http without extra-movements, no intermediate volumes are needed

SPA-to-HTTP vs Nginx performance benchmark

Environment:

  • 11th Gen Intel(R) Core(TM) i9-11900H @ 2.50GHz
  • 64-bit, Ubuntu 20.04.3 LTS on WSL
  • Docker Desktop, engine 20.10.11

To perform experiment we created Vue 3 project using:

vue create benchmark

And created Dockerfile there, here is it content for Spa-to-http test:

FROM node:16-alpine as builder
WORKDIR /code/
ADD package-lock.json .
ADD package.json .
RUN npm ci
ADD . .
RUN npm run build

FROM devforth/spa-to-http:1.0.3
COPY --from=builder /code/dist/ . 

And the Dockerfile for Nginx test:

FROM node:16-alpine as builder
WORKDIR /code/
ADD package-lock.json .
ADD package.json .
RUN npm ci
ADD . .
RUN npm run build 

FROM nginx:1.23.1
COPY --from=builder /code/dist/ /dist/
ADD server.conf /etc/nginx/conf.d

And of-course Nginx requires config file, so here is my default, pure server.conf:

server {
    listen 8080;
    root /dist;
    index index.html;
    try_files $uri /index.html;
access_log off; }

Before running tests we stopped all containers and run one dedicated container with a server:

docker run -p 8080:8080 --rm -it $(docker build -q .)

During each test we used wrk to profile code: 

wrk -t20 -c1000 -d60s --latency  http://127.0.0.1:8080/

Here is what we got:

Nginx vs Spa-to-http performance on index.html (491 Bytes), higher is better
Nginx vs Spa-to-http performance on index.html (491 Bytes), higher is better

We also did the same on larger file:

wrk -t20 -c1000 -d60s --latency  http://127.0.0.1:8080/js/app.33fe7a9b.js

The result looks same:

Nginx vs Spa-to-http performance on app.js (3.8 KiB), higher is better
Nginx vs Spa-to-http performance on app.js (3.8 KiB), higher is better

You might probably achieve much better Nginx performance by doing tunings using tones of it's settings, but here we consider only default configs which could be used by average developer with a high probability.

To perform port availability test we used next bash script:

#!/bin/bash
docker kill $(docker ps -q) docker rm $(docker ps -a -q) docker rmi $(docker images -q) docker build . -t availability_time_tester experiments='' for i in `seq 1 100`; do docker run -p 8080:8080 --name availability_time_tester availability_time_tester & start=`date +%s.%N` while true; do curl -s http://127.0.0.1:8080 > /dev/null if [ $? -eq 0 ]; then break fi done docker kill availability_time_tester docker rm availability_time_tester end=`date +%s.%N` dur=$( echo "$end - $start" | bc -l ) experiments=`echo $experiments $dur` echo "Times $experiments" done

Then received 100 values were averaged, here is results:

AverageVarianceMaxMin
Spa-to-http1.350.093.071.12
Nginx1.510.042.31.33

Conclusion

Spa-to-http could be integrated faster and easier into any web project, has 10 times smaller Docker image, has faster spawn time, and performs same or even faster then Nginx.

Useful links