Slow loading, self-hosted apps

I was loading Mealie the other day, my self-hosted recipe app. It's really just for me and family members, but it was taking 5 seconds to load. Certainly, not an issue among family, but I was unsatisfied with the experience. I pulled up DevTools, exported a HAR file, and began sleuthing.

Finding the bottleneck

My first thought was that something was wrong with Mealie itself. Maybe a slow database query or a heavy API call. But when I looked at the HAR file, it wasn't one big slow request. It was 150 small ones, each taking way longer than they should.

I started checking response headers and noticed something. The HTML coming back had a header I hadn't paid attention to before: Content-Encoding: gzip. I checked the CSS files and wasn't seeing the same header. Checked JavaScript too, nothing. A 500 KB JavaScript bundle was being sent as a full 500 KB.

I checked a few of my other self-hosted apps. Invoice Ninja, Easy Appointments, same story. None of the JS or CSS had that header.

So what was gzip?

What gzip does

Gzip compresses text-based files before sending them, and the browser decompresses on arrival. It handles this natively. For JS, CSS, JSON, and SVGs, compression ratios are usually 60-85%. For already compressed files like JPEG or PNG, it doesn't help.

So my HTML was compressed, but nothing else was. The apps I tested didn't seem to be handling compression on their end either, and neither was my reverse proxy. I looked into it and found that my proxy was only compressing HTML by default. Everything else was passing through at full size. That explained the 5-second loads.

The fix

Six lines added to my Nginx config.

gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml font/ttf font/woff2;
gzip_min_length 256;
gzip_comp_level 6;
gzip_vary on;
gzip_proxied any;

I put this in the http block so it applies to every site on the server. After a quick sudo nginx -t && sudo systemctl reload nginx, I reloaded Mealie. In another browser, private, and with VPN enabled to truly start from a scratch load.

0.4 seconds. Down from 5.

Results

I went through my other apps and enabled the same config. Here's what happened to the five largest assets on one of them.

File                          Before      After     Saved
─────────────────────────────────────────────────────────
main.js                      423.7 KB   116.0 KB    72.6%
resume.js                    512.1 KB   150.7 KB    70.6%
section.js                   495.5 KB    80.2 KB    83.8%
main.css                      62.7 KB    11.8 KB    81.2%
globals.css                  164.8 KB    24.1 KB    85.4%
─────────────────────────────────────────────────────────
TOTAL                       1658.8 KB   382.9 KB    76.9%

And the load times across apps:

App                    Before      After
──────────────────────────────────────────
Mealie                  5.0s       0.4s
Invoice Ninja           1.2s       0.2s
Easy Appointments       2.5s       0.3s

Tutorial: setting this up yourself

I use Nginx, so the examples below are Nginx-specific. If you're on something else, the concept is the same but the config will look different.

Raw Nginx

Open your Nginx config. This could be /etc/nginx/nginx.conf, a file in /etc/nginx/sites-available/, or a server block for a specific app.

Add the gzip block inside http to apply globally, or inside a specific server block for one site.

gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml font/ttf font/woff2;
gzip_min_length 256;
gzip_comp_level 6;
gzip_vary on;
gzip_proxied any;

Test and reload:

sudo nginx -t && sudo systemctl reload nginx

Nginx Proxy Manager

Same config, just delivered through the UI.

  1. Log into your NPM dashboard
  2. Click Proxy Hosts
  3. Find your app's proxy host, click the three-dot menu, then Edit
  4. Go to the Advanced tab
  5. Paste the same six lines
  6. Hit Save. NPM reloads Nginx automatically.

One downside with NPM: you can't set this globally. The Advanced tab is per proxy host, so you have to paste it into each one individually. With raw Nginx you'd put it in the http block once and it covers everything.

I was also researching brotli, which is a newer compression algorithm that supposedly compresses better than gzip. I tried adding brotli directives but NPM errored on save. Turns out NPM's bundled Nginx doesn't ship with the brotli module, so it's gzip only here.

Verify it's working

After enabling, curl one of your JS or CSS files and check for the Content-Encoding header:

curl -v -H "Accept-Encoding: gzip" https://yoursite.com/assets/main.js -o /dev/null 2>&1 | grep -i content-encoding

If you see Content-Encoding: gzip, you're good.

What each directive does

gzip on — enables compression. Only text/html by default, which is why the next line matters.

gzip_types — the MIME types to compress. JS, CSS, JSON, XML, SVGs, and web fonts. Binary formats like JPEG and PNG aren't listed because they're already compressed.

gzip_min_length 256 — skips files smaller than 256 bytes. The gzip header adds overhead, so tiny files can actually get bigger.

gzip_comp_level 6 — compression strength, 1-9. I went with 6 after reading that the higher levels give diminishing returns on compression while costing more CPU. Seemed like a reasonable middle ground.

gzip_vary on — adds a Vary: Accept-Encoding header so caches know there are compressed and uncompressed versions of the same resource.

gzip_proxied any — this is the one that got me. Nginx won't compress proxied responses by default. This tells it to compress everything regardless of upstream cache headers. Without it, none of the other lines matter if you're using Nginx as a reverse proxy.

Wrapping up

Six lines of config. If your self-hosted apps feel slow, it's worth checking if your reverse proxy is compressing responses. Mine wasn't.

Hope this helps someone, someday, with Nginx configs. Who knows? Google can bring up the most obscure things in search sometimes.