Code of the Day
AdvancedWeb Performance & Delivery

Asset Optimization

Minification, bundling, caching, and compression reduce the bytes a browser downloads — most of this is handled by build tools, but understanding what they do lets you configure them deliberately.

Web FoundationsAdvanced7 min read
Recommended first
By the end of this lesson you will be able to:
  • Explain what minification, bundling, and tree shaking do and what size reductions to expect
  • Describe code splitting and why it reduces initial parse time
  • Contrast gzip and Brotli compression and explain that it is a server configuration concern
  • Write a correct Cache-Control header for an immutably hashed asset

Images and fonts travel from server to browser as binary files — their sizes are set at authoring time. JavaScript and CSS are different: they are text files that a build pipeline can transform before they ever leave the server. The pipeline can remove dead code, combine files, rename variables to single letters, and compress bytes by 60–80%. Understanding each transformation tells you how to configure it intentionally — and how to diagnose when something is wrong.

Minification

removes every byte that is syntactically optional: whitespace, comments, long variable names in local scope, and in some cases performs constant folding and expression simplification.

// Before minification — 180 bytes
function calculateDiscount(price, percentage) {
  // Apply the discount
  const discount = price * (percentage / 100);
  return price - discount;
}

// After minification — 65 bytes
function calculateDiscount(p,e){const d=p*(e/100);return p-d}

The output is semantically identical — same runtime behaviour, just harder for humans to read. Minification is non-destructive: source maps let DevTools show you the original, readable source even when the browser runs minified code.

Typical size reduction: 20–40% for JavaScript; similar for CSS. HTML minification (removing whitespace and optional quotes) saves less but is still worth doing for large documents.

Bundling

Historically, a page might load 30+ separate CSS and JavaScript files. Each file is a separate HTTP request with its own TCP overhead, header negotiation, and queuing delay. Bundling combines them into fewer files:

Before:
  main.js (2 KB)
  utils.js (5 KB)
  analytics.js (8 KB)
  → 3 requests

After bundling:
  app.bundle.js (15 KB)
  → 1 request

With HTTP/1.1, bundling was essential because browsers limited concurrent connections per domain. With HTTP/2 multiplexing, many files can share one connection without queuing overhead — so bundling matters less for request count. But it still matters for parse time: one 50 KB file is parsed faster than fifty 1 KB files because JavaScript parsing overhead has a fixed per-file cost. Modern build tools bundle by default but can be tuned.

Code splitting

The natural counterweight to bundling is : deliberately dividing the bundle into chunks so that the homepage does not need to download, parse, and compile the checkout page's code.

// Without code splitting — checkout code always included
import { CheckoutForm } from './checkout';

// With dynamic import — checkout code loads only when needed
const { CheckoutForm } = await import('./checkout');

A dynamic import() call tells the bundler (Vite, webpack, Rollup, esbuild) to emit that module as a separate chunk file. The browser downloads it only when execution reaches that import() call. For a large application, route-based code splitting reduces the initial JavaScript payload by 30–70% — directly improving both INP (less code to parse before the page is interactive) and LCP (less network competition).

Build tools like Vite and Next.js apply route-based code splitting automatically. When you add a new page, it becomes its own chunk with no manual import() needed. Understanding that this is what they are doing helps you interpret build output and diagnose bundle size regressions.

Tree shaking

is dead-code elimination at bundle time. Because ES modules have static import / export declarations, a bundler can determine at build time exactly which exports are used and which are not:

// You import only debounce from lodash-es
import { debounce } from 'lodash-es';

// The bundler analyses the dependency graph and omits
// the other 200+ functions that lodash exports

The unmoved parts are "shaken" out of the tree — hence the name. The result is that import { debounce } from 'lodash-es' produces a bundle containing only debounce and its internal dependencies, not the entire lodash library.

Tree shaking requires ES module syntax (import/export). CommonJS (require/module.exports) is dynamic and cannot be statically analysed the same way — which is why many libraries ship both dist/cjs/ and dist/esm/ variants.

Compression

Every text asset — HTML, CSS, JS — is compressible at the network layer. The server compresses the response body before sending it, and the browser decompresses it transparently before parsing:

AlgorithmBrowser supportTypical reduction
gzipUniversal60–70%
Brotli95%+ (excludes IE)70–80%

Brotli was developed by Google specifically for web content and consistently beats gzip by 10–20% on JavaScript and CSS. Both require server or CDN configuration — no code changes.

# nginx: enable both
gzip on;
gzip_types text/css application/javascript;

brotli on;
brotli_types text/css application/javascript;

The browser signals what it supports via the Accept-Encoding request header; the server responds with the best match and sets Content-Encoding accordingly. This is invisible in your source code and your bundle output — it happens in the delivery layer.

Caching

Once a browser has downloaded an asset, it should not need to download it again unless the content has changed. The Cache-Control response header controls this:

# Fingerprinted asset — safe to cache forever
Cache-Control: max-age=31536000, immutable

# HTML document — always revalidate
Cache-Control: no-cache

Fingerprinting is the practice of including a hash of the file's contents in its filename: main.a3f92d.js. When the source changes, the hash changes, the filename changes, and the browser treats it as a brand-new resource — busting the old cache entry automatically. The immutable directive is an additional hint to the browser that the content truly will never change at this URL, so it should not even send a conditional request to validate it on repeat visits.

The HTML document itself must not be cached with a long max-age — it is the pointer to the fingerprinted assets, and you need users to always fetch the latest HTML so they get the correct asset references.

A common mistake is setting Cache-Control: max-age=31536000 on all assets including the HTML file. This means users who have visited your site will load the old HTML (with old asset references) for up to a year, even after you deploy. Cache aggressively on fingerprinted filenames; revalidate always on the HTML.

The build pipeline as a whole

These transformations compose into a single pipeline that runs before deployment:

Source files
  ↓  tree shaking (remove dead exports)
  ↓  bundling + code splitting (fewer, smarter chunks)
  ↓  minification (remove optional bytes)
  ↓  fingerprinting (content-hash filenames)

Output: dist/
  main.a3f92d.js     → served with Cache-Control: immutable
  vendor.b7c14e.js   → served with Cache-Control: immutable
  index.html         → served with Cache-Control: no-cache

  ↓  compression (server/CDN layer)
  ↓  delivered to browser

You do not configure most of this manually — Vite, webpack, and esbuild handle it out of the box with reasonable defaults. But when a performance audit flags a large bundle, knowing the pipeline tells you exactly where to look: is it a tree-shaking failure (an un-shakeable CommonJS dependency)? A missing code split (a large route included in the initial chunk)? A missing compression header?

Where to go next

You have covered the full stack of performance levers: measuring with Core Web Vitals, optimising images and fonts, and understanding what the build pipeline does to JavaScript and CSS. The Lab — Improve Core Web Vitals lesson puts these tools together on a single broken page and walks through fixing each metric systematically.

Finished reading? Mark it complete to track your progress.

On this page