Brotli Dockerized Angular App with Nginx - NgDocker

Zaki Mohammed Zaki Mohammed
Jul 16, 2023 | 5 min read | 1494 Views | Comments

Patience is the key to success; not heard or followed by any of the web app visitors. We want everything to be loaded within blink of an eye. This is the place where Brotli shines. In this article, we will explore how we can enable Brotli in the NgDocker app to improve performance at a marvelous scale.

If you love breads/rolls then you will surely love Brotli (Brotli: it's a Swiss German word for a bread roll). Brotli is level up compression algorithm, faster than Gzip, widely available, developed by Google under MIT license. So, no valid reason available for not using it. There are some useful articles where we can understand the back story of Brotli compression and why it's better than Gzip.

You can check these links: 

In this article our focus will be to check how we can enable Brotli Compression with Angular, Docker, Nginx kind of setup. We will take the same app from this article Gzip Dockerized Angular App with Nginx - NgDocker and instead of Gzip we will enable Brotli.

We will head in this direction:

  1. Setup Angular App
  2. Create a Dockerfile with Brotli
  3. Enable Brotli in Nginx Configuration
  4. Run Docker Container
  5. Celebrate the Impact of Brotli
  6. Brotli vs Gzip vs No Encoding

Setup Angular App

Similar to NgDocker mark 2, we will do the daily chores of Angular app and as per the need of the hour we are considering v16 of Angular; just for fun we are using Cirrus UI:

ng new ng-docker-mark-4

npm i cirrus-ui

Below shows the skeleton of the project:

ng-docker-mark-4
|-- nginx
|   |-- nginx.conf
|-- src
|   |-- app
|   |   |-- core
|   |   |   |-- components
|   |   |   |   |-- footer
|   |   |   |   |-- header
|   |   |   |-- pages
|   |   |   |   |-- home
|   |   |   |-- services
|   |   |       |-- core.service.ts
|   |   |       |-- products.service.ts
|   |   |-- modules
|   |   |   |-- posts
|   |   |   |-- users
|   |   |-- app-routing.module.ts
|   |   |-- app.component.html
|   |   |-- app.component.ts
|   |   |-- app.module.ts
|-- angular.json
|-- Dockerfile
|-- package.json

This application respect minimalism and have less number of routes and components. For the sake of understanding how compression affects the lazy loaded modules we have added 2 feature standalone modules Posts and Users. Thanks to Angular v15 these are standalone components routed in a lazy loaded fashion. The data is coming from our beloved JSON Placeholder API.

Below shows the business logic for Posts component, the Users component is also created in the similar manner:

posts.component.html

<div class="section">
  <div class="content">
    <div class="row mb-3" *ngFor="let post of posts$ | async">
      <div class="col-sm-4">
        <img
          class="img-stretch u-round-md"
          [src]="getImage(post)"
          alt="img-stretch"
          height="400"
          (error)="getDefaultImage($event)" />
      </div>
      <div class="col-lg-6 col-md-8">
        <h3>{{ post.title | titlecase }}</h3>
        <p>{{ post.body }}</p>
        <div>
          <span class="icon">
            <i class="far fa-wrapper fa-clock" aria-hidden="true"></i>
          </span>
          {{randomDate | date: 'fullDate'}}
        </div>
      </div>
    </div>
  </div>
</div>

posts.component.ts

posts$?: Observable;

constructor(private coreService: CoreService) {
  this.posts$ = coreService.getPosts();
}

Below shows the post service, the user service is also created in similar manner:

products.service.ts

private url: string = environment.apiUrl;

constructor(private http: HttpClient) {}

get(name: string) {
  const url = `${this.url}${name}`;
  return this.http.get(url);
}

Below shows the routes of the application:

app-routing.module.ts

const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'posts', loadComponent: () => import('./modules/posts/posts.component').then(x => x.PostsComponent) },
  { path: 'users', loadComponent: () => import('./modules/users/users.component').then(x => x.UsersComponent) },
  { path: '**', component: HomeComponent },
];

Create Dockerfile with Brotli

After fighting a constant battle with ChatGPT to get the correct answer, I came to final solution that actually works to enable Brotli compression with Dockerized Angular app; thanks to StackOverflow user stwissel (StackOverflow Question).

Our Dockerfile must be looking like below:

# Build container
FROM node:18-alpine AS builder
WORKDIR /app

# Make sure we got brotli
RUN apk update
RUN apk add --upgrade brotli

# NPM install and build
ADD package.json .
RUN npm install
ADD . .
RUN npm run build:prod

RUN cd /app/dist && find . -type f -exec brotli {} \;

# Actual runtime container
FROM alpine
RUN apk add brotli nginx nginx-mod-http-brotli

# Minimal config
COPY nginx/nginx.conf /etc/nginx/http.d/default.conf

# Actual data
COPY --from=builder /app/dist/ng-docker-mark-4 /usr/share/nginx/html
CMD ["nginx", "-g", "daemon off;"]
EXPOSE 80

Here, most of the thing seems pretty normal and related to default Angular-Docker setup. We will highlight the important steps those are needed for Brotli.

# Make sure we got brotli
RUN apk update
RUN apk add --upgrade brotli

Here, the first line updates the package index inside the container. The second line installs or upgrades the "brotli" compression library inside the container.

RUN cd /app/dist && find . -type f -exec brotli {} \;

Here, the statement "cd /app/dist" changes the working directory to "/app/dist" and the statement "find . -type f -exec brotli {} \;" finds all files in the current directory and its subdirectories ("."), then applies the "brotli" compression command to each file ("{}").

# Actual runtime container
FROM alpine
RUN apk add brotli nginx nginx-mod-http-brotli

Here, the first line specifies the base image for the runtime container. In this case, it uses the "alpine" image, which is a lightweight Linux distribution. The second line installs the "brotli" library, "nginx" web server, and the "nginx-mod-http-brotli" module inside the container.

Enable Brotli in Nginx Configuration

The time has come to enable Brotli in Nginx configuration file which we have seen in the previous article for re-writing the route for SPA:

server {
    brotli on;
    brotli_static on;
    brotli_comp_level 6;
    brotli_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
    ...
}

That's it, no need to check or look anywhere else, this much is required for super speeding up your app performance! Also, these settings are self explanatory, the first 2 lines enables the Brotli and Brotli for static files, followed by setting the compression level to 6 that ranges from 0 to 11 (default is 6), then we are mentioning the types for which the Brotli will be enabled (note: no need to compress already compressed images like JPG).

Run Docker Container

For this we require to have Docker Desktop, otherwise we are unworthy to proceed.

Build Docker Image

# build image
docker build -t ng-docker:mark-4 .

Run Docker Container

# run container
docker run -p 3300:80 --name ng-docker-mark-4-container ng-docker:mark-4

Here, you can change to your favorite port, we are going with "3300" for a change. Once, executed then open `http://localhost:3300/` to run the application.

Celebrate the Impact of Brotli

Its time for the celebration! Due to drastic improvement in the performance. Here, we will compare the time to load files with and without Brotli through below screen shots:

No Content Encoding (before)

No Content Encoding | NgDocker | Mark 4 | CodeOmelet

Gzip Enabled (before)

Gzip Enabled | NgDocker | Mark 4 | CodeOmelet

Brotli Enabled (after)

Brotli Enabled | NgDocker | Mark 4 | CodeOmelet

Here, we can clearly see the before and after effect on the file size, the biggest file (main.js) early which was around 265KB turns out to be of 97.7KB of size after enabling the Gzip and then finally it becomes 72.5KB once Brotli is enabled. The snowball effect can clearly be seen in other files too. The "Content-Encoding" header usually doesn't appear in the browser's Network tab of Developer Tools, we can add it by simply right clicking on the table of Network tab and go to "Headers" option and then add the "Content-Encoding".

Similar effect can also be observed with lazy-loaded modules:

Lazy Loaded Module - No Content Encoding (before)

Lazy Loaded Module - No Content Encoding | NgDocker | Mark 4 | CodeOmelet

Lazy Loaded Module - Brotli Enabled (after)

Lazy Loaded Module - Brotli Enabled | NgDocker | Mark 4 | CodeOmelet

Brotli vs Gzip vs No Encoding

It's time to compare everything with everything. Not holding back any punches and comparing 3 scenarios for the base JavaScript file here that is "main.js".

Below tables shows the original file size of "main.js" without any content encoding and after encoding using Gzip and Brotli:

Content EncodingFile Size (main.js)
None265 KB
Gzip (gzip)97.7 KB
Brotli (br)72.5 KB

Below tables shows the percentage differences between no encoding to Gzip to Brotli for "main.js":

FromToDifference
None [265 KB]Gzip [97.7 KB]92.2526%
None [265 KB]Brotli [72.5 KB]114.074%
Gzip [97.7 KB]Brotli [72.5 KB]29.6122%

The Brotli compression from None to Brotli is insane around 114% while from None to Gzip is around 92%. The difference between Brotli and Gzip is around 30%.

So, in a toe-to-toe comparison Brotli is clearly winner here. NOTE: We are only comparing based on JavaScript file.

In this article The Difference Between Brotli And Gzip Compression Algorithms To Speed Up Your Site, it is mentioned that the benchmark difference between Gzip and Brotli is as follows:

  • 14% smaller JavaScript files
  • 21% smaller HTML files
  • 17% smaller CSS files

Zaki Mohammed
Zaki Mohammed
Learner, developer, coder and an exceptional omelet lover. Knows how to flip arrays or omelet or arrays of omelet.