Ship less, cache the rest
In short: every visitor downloaded the whole app — including features they would never open — on every single visit. Now the main bundle is a third smaller, niche features load on demand, and a repeat visit downloads ~50× less.
Context
The same platform as the other case studies — an Angular/Nx monorepo served by nginx from a container. Years of growth had piled everything into one eager bundle, and the delivery pipeline had drifted: the "prod" build wasn't building on the production configuration, and the server told browsers to cache nothing.
Problem
Every user paid for everything. A specialist feature used by a narrow
group shipped ~1.5 MB into everyone's main bundle; an audio library
loaded eagerly for views most users never open;
a date library pulled in ~100 locales when the app supports 12. And
because every asset was served no-cache, returning users
re-downloaded the entire app on every visit — fonts, icons and all.
Key changes
| Change | Before | After | Gain |
|---|---|---|---|
| Lazy-loaded a niche feature module | ~1.5 MB raw / ~400 KB gzip of a specialist feature (a German drinking-water norm) shipped eagerly to every user | own lazy chunk, loaded only when its route is visited — with its services moved into the lazy injector | pages-main chunk −91% |
| Dynamic import for the audio library | Howler statically imported — 108 KB raw in main for a player used on a few views | import type + await import() at the use site — loaded on first play | −108 KB raw / ~30 KB gzip |
| Locale tree-shaking | import * as locales from 'date-fns/locale' dragged in ~100 locales of the whole world | named imports for the 12 actually supported locales, resolved via a static map | −150 KB raw / ~40 KB gzip |
| Dependency cleanup | moment-timezone duplicating date-fns-tz, an unused 3D library, CJS lodash, devtools in prod deps | duplicates and dead deps removed, lodash → lodash-es, build tools reclassified as devDependencies | hundreds of KB out of the bundle |
| Production build that is actually production | nx build without -c production — prod artifacts built on default configuration | explicit production config for prod and stg, a dedicated stg target, consistent env naming | tree-shaking & AOT actually on |
| Cache policy that uses the browser | nginx served every static asset with no-cache — returning users re-downloaded everything, and a broken regex could put translations into long cache | hashed assets: 1-year immutable; translations/config: explicit no-store; fixed regex, one clean Cache-Control per response, ETag revalidation back on | repeat visit ~2.5 MB → ~50 KB |
| Precompressed responses | nginx gzipped every response on the fly (level 6) | gzipper precompresses at build time (level 9), nginx serves .gz via gzip_static | lower CPU, faster TTFB, better ratio |
| webpack → esbuild application builder | deprecated webpack-browser executor, webpack-dev-server | Angular's application builder (esbuild) for both apps, Vite-based dev server, webpack-only hacks replaced with clean ESM imports | prod build ~2 min → ~36 s |
Measured (HAR, cold load)
| Metric | Before | After | Delta |
|---|---|---|---|
| main.js (raw) | 7.18 MB | 4.65 MB | −35% |
| main.js (gzip) | 1.70 MB | 1.33 MB | −22% |
| Cold-load transfer | ~2.47 MB | ~2.08 MB | −16% |
| Repeat-visit transfer | ~2.5 MB — no-cache re-downloaded everything | ~50 KB — index.html + two JSONs | −98% |
| Pages-main chunk | 1.55 MB raw (eager niche feature inside) | 0.14 MB raw | −91% |
| Production build time | ~2 min (webpack) | ~36 s (esbuild) | −70% |
Bugs fixed along the way
Delivery work keeps surfacing latent bugs, and this round was no
exception: SPA deep links 404'd on hard reload (missing
try_files), the staging pipeline silently built the test
target, a duplicated Cache-Control header left caching
behavior up to whichever proxy interpreted it, and the lazy-loading
migration exposed a service provided in the wrong injector — fixed by
moving it into the lazy module where its dependencies live.
The pattern repeats
This is the second time the same playbook paid off — an earlier round at a US social-commerce unicorn cut that app's production bundle by ~40% with the same moves: lazy load what's niche, tree-shake what's imported too broadly, delete what's duplicated.
The fastest bytes are the ones you never ship — and the second-fastest are the ones you ship once. Lazy-load the niche, prune the duplicate, and let the browser cache do the job it was built for.