CSS and JavaScript Asset Optimization

A static site's HTML arrives fast from the edge; what happens next depends on what that HTML references. A 180 KB stylesheet that blocks rendering, a framework runtime shipped to pages that do not use it, fonts and icons loaded as separate files, assets compressed at whatever level the CDN picked — each adds hundreds of milliseconds to first render on a mid-range phone, and together they are why static sites with excellent time to first byte still fail Largest Contentful Paint.

This guide covers the CSS and JavaScript side of that equation: making critical CSS arrive with the HTML, removing the rules nobody uses, splitting JavaScript so pages load only what they need, minifying and precompressing everything that ships, and choosing an icon strategy that does not cost a request per glyph. It sits within Performance Optimization & Core Web Vitals for SSGs, next to JavaScript Hydration & Partial Rendering, which decides how much JavaScript a page needs at all, and Font Loading Strategies for Static Sites.

The measurements come from two sites: an Astro marketing and documentation site (410 pages, Tailwind CSS, a handful of React islands) and an Eleventy documentation site (1,300 pages, hand-written CSS, vanilla JavaScript). Lab numbers are Lighthouse 12 mobile runs (Moto G Power, slow 4G) as the median of five, field numbers are p75 from the sites' own web-vitals RUM.

Stages of the asset pipeline from source to browser Five layers from source to browser. Source CSS and JavaScript are first reduced by removing unused rules and code. The remainder is split into critical and deferred parts. Everything is minified. Files are precompressed with brotli and zstd. Finally the browser receives a small inline critical block, cached hashed files and on-demand chunks. Where CSS and JavaScript bytes are spent Source CSS and JS framework, components, utilities 184 KB CSS and 142 KB JS before Remove unused purge CSS, tree-shake JS largest single saving Split and schedule critical CSS inline, JS per route controls render blocking Minify lightningcss, esbuild, html-minifier 15-30% before compression Precompress brotli 11 and zstd 19 about 17% smaller than CDN defaults
Each layer removes or reschedules bytes; the order matters because compression works best on output that is already small and minified.

Critical CSS: What Blocks the First Paint

Browsers do not paint until they have the CSS referenced in the <head>. On a fast connection a cached stylesheet costs nothing, but on a first visit over slow 4G, a render-blocking stylesheet request adds a full round trip plus transfer time before anything appears — on the Eleventy site, 610 ms of its 1.9 s First Contentful Paint.

There are three ways to reduce that:

  1. Make the stylesheet small enough to inline entirely. Under about 15 KB compressed, inlining the whole stylesheet into each page's <head> removes the request. The Eleventy site's CSS was 11 KB brotli-compressed after cleanup, so it inlined everything and dropped FCP to 1.3 s.
  2. Inline only the critical rules — the ones needed for above-the-fold content — and load the full stylesheet without blocking. Tools such as beasties (the maintained fork of critters) compute this at build time per page.
  3. Split CSS by template so each page type links only its own rules, which Astro does automatically for component styles.

Inlining has a cost: the CSS is re-downloaded with every HTML page instead of being cached once. For a site where readers view many pages per session, a cached external file can win on the second page. Inlining Critical CSS on Static Sites measures that trade-off.

// astro.config.mjs — Astro inlines small stylesheets automatically
export default defineConfig({
  build: { inlineStylesheets: 'auto' },   // inline CSS under vite.build.assetsInlineLimit (4 KB default)
  vite: { build: { assetsInlineLimit: 8192 } }
});
Timeline of first paint with external and inlined CSS A timeline for the Eleventy site on a slow 4G profile. HTML arrives at 560 milliseconds. With an external stylesheet, the CSS request finishes at 1,170 milliseconds and first contentful paint happens at 1.9 seconds. With the stylesheet inlined, first contentful paint happens at 1.3 seconds, right after the HTML is parsed. First visit on slow 4G, external versus inlined CSS HTML received CSS file loaded FCP inlined FCP external 560 ms both variants 1.17 s external variant 1.3 s no CSS request 1.9 s render blocked until CSS
Lighthouse mobile, median of five runs; the 600 ms gap is almost exactly one round trip plus the CSS transfer on the throttled profile.

Removing Unused CSS

The largest CSS saving on most sites is deletion. Utility frameworks generate classes on demand now — Tailwind 3 and 4 scan source files and emit only used utilities — but component libraries, old theme CSS and "just in case" stylesheets accumulate rules no page uses. The Astro site shipped 184 KB of CSS, of which Chrome DevTools' Coverage panel reported 81% unused on the home page and 76% on a typical docs page.

The fix depends on where the waste comes from. For Tailwind, make sure the content globs cover every template and nothing else, and remove any plugin-generated CSS you do not use. For legacy CSS, a purge step with PurgeCSS, or Lightning CSS with an unused-symbols list, removes selectors that never match the built HTML. The risk is removing classes that are added at runtime by JavaScript, which need a safelist. Purging Unused CSS with Tailwind and Lightning CSS covers both.

Measure (Astro site)BeforeAfter purge
CSS on docs pages (brotli)31 KB9 KB
CSS on docs pages (uncompressed)184 KB41 KB
Unused bytes (DevTools Coverage)76%18%
LCP p75, field2.4 s2.1 s
Stylesheet size before and after purging on the Astro site Paired bars. Uncompressed CSS on docs pages went from 184 kilobytes to 41 kilobytes. Brotli-compressed CSS went from 31 kilobytes to 9 kilobytes. Unused share reported by DevTools Coverage went from 76 percent to 18 percent. CSS weight before and after removing unused rules Before purge After purge CSS uncompressed (KB) 184 41 CSS brotli (KB) 31 9 Unused share (%) 76 18
Measured on the built output with brotli -q 11 and Chrome DevTools Coverage on a representative docs page.

Splitting JavaScript by Route and Interaction

On a static site, JavaScript should arrive only where it is used and only when it is needed. Vite-based generators (Astro, VitePress, SvelteKit's static adapter) split bundles automatically along dynamic import() boundaries and emit modulepreload hints for the chunks each page needs. The two mistakes that undo this are importing a heavy library at the top of a shared layout module, which drags it into every page, and preloading chunks that are only needed after an interaction.

The Astro site's search dialog imported a 38 KB (compressed) fuzzy-search library at module scope in the header component; every page downloaded it on load, though only 4% of sessions opened search. Moving the import inside the click handler removed it from the initial load of every page, and client:idle on the island delayed the remaining header script until after the main content rendered. Code Splitting and Module Preload in Vite-Based SSGs walks through finding and fixing these with rollup-plugin-visualizer.

// Before: loaded on every page
import Fuse from 'fuse.js';

// After: loaded on first open of the search dialog
button.addEventListener('click', async () => {
  const { default: Fuse } = await import('fuse.js');
  openSearch(new Fuse(await loadIndex(), { keys: ['title', 'body'] }));
}, { once: true });
Initial JavaScript bytes on the Astro site after each change Horizontal bars of compressed JavaScript loaded on first view of a docs page. Baseline 142 kilobytes. After lazy-loading the search library 104 kilobytes. After replacing the React theme toggle with a small script 61 kilobytes. After moving the feedback widget to client:visible 44 kilobytes. JavaScript on first load of a docs page Baseline 142 KB Search library loaded on click 104 KB Theme toggle as plain script 61 KB Feedback widget on client:visible 44 KB
Brotli-compressed bytes from the Lighthouse network requests table, docs page template, median of five runs.

Minification and Precompression

Minification removes whitespace, comments and redundant syntax. It is cheap and worth doing for HTML, CSS and JavaScript, but its benefit after compression is modest — on the Eleventy site, minifying HTML saved 22% uncompressed and 7% after brotli. esbuild and Lightning CSS minify JS and CSS in milliseconds; HTML minification is a post-build transform. Details and the settings that break things (collapsing whitespace inside <pre>, removing optional closing tags that some tools depend on) are in Minifying HTML, CSS and JS in Eleventy.

Compression matters more. CDNs compress responses on the fly, but at moderate levels chosen for speed: Cloudflare uses brotli around level 4 by default for dynamic compression. Precompressing at build time with brotli at level 11 and zstd at level 19 produced files 14–19% smaller on both sites, and hosts that serve precompressed variants — Caddy, nginx with brotli_static, Cloudflare Workers static assets and Netlify for some file types — deliver them with no per-request work. Precompressing Assets with Brotli and Zstd has the build step and host configuration.

# after the build: precompress text assets at maximum levels
find dist -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' -o -name '*.svg' -o -name '*.json' \) \
  -size +1k -print0 | xargs -0 -P "$(nproc)" -I{} sh -c 'brotli -q 11 -k -f "{}" && zstd -19 -q -k -f "{}"'
Transfer size of HTML, CSS and JS with different compression settings Vertical bars of total transfer for one Eleventy docs page's HTML, CSS and JS. Uncompressed 212 kilobytes. gzip level 6 on the fly 58 kilobytes. Brotli level 4 on the fly 52 kilobytes. Brotli level 11 precompressed 43 kilobytes. One docs page, total transfer by compression 212 KB Uncompressed 58 KB gzip 6 on the fly 52 KB brotli 4 on the fly 43 KB brotli 11 precompressed
Sizes measured with curl --compressed against each configuration and summed across the three requests.

Icons Without a Request per Glyph

Icon strategy decides whether a page's chrome costs one request, twenty, or none. Icon fonts are the worst option today: a full Font Awesome WOFF2 is over 100 KB for the dozen glyphs a site uses, it blocks icon rendering until loaded, and it fails for readers who override fonts. Individual SVG files as <img> elements cost a request each. The two good options are inline SVG — each icon's markup embedded in the HTML, zero requests, styleable with currentColor — and an external SVG sprite referenced with <use href="/icons.svg#search">, one cached request for all icons.

On the Eleventy site, replacing a 108 KB icon font with inline SVGs for 14 icons added 2.1 KB of compressed HTML per page and removed the font request; the header icons now render with the first paint instead of 400 ms later. Inline wins for small, fixed sets; sprites win when pages use many different icons or the HTML size matters more than one request. SVG Icon Sprites vs Inline Icons compares them in detail.

Generator Defaults Compared

How much of this a generator does for you varies widely, and it is worth knowing before you add tooling that duplicates a built-in feature.

Astro bundles and minifies CSS and JavaScript with Vite, scopes component styles so each page only links the CSS of the components it renders, inlines small stylesheets with build.inlineStylesheets: 'auto', and ships zero JavaScript for components without a client:* directive. What it does not do is remove unused rules inside a stylesheet you import globally, precompress output, or minify HTML (it emits compact HTML but keeps whitespace in some templates).

Eleventy does none of it by default: it writes the templates' output and copies assets as they are. Everything in this guide is a plugin or a few lines of configuration — Lightning CSS and esbuild in eleventy.before, an HTML transform, a precompression script. The upside is that each step is visible and optional.

Hugo has Hugo Pipes: resources.Get | css.Sass | minify | fingerprint handles Sass, minification and content hashing in templates, js.Build bundles JavaScript with esbuild, and --minify minifies HTML, CSS, JS, JSON, SVG and XML output in one flag. Unused-CSS removal needs PostCSS with PurgeCSS via css.PostCSS, fed by Hugo's build.writeStats list of classes used in the built HTML.

Next.js static export minifies JavaScript and CSS, splits JavaScript per route and inlines critical CSS only with the experimental optimizeCss option. It ships the React runtime on every page, which sets a floor on JavaScript weight that the other generators do not have — see Next.js Static Export vs Astro for Marketing Sites.

Whatever the generator, measure the built output rather than trusting defaults. Both sites here had features "handled by the framework" that were not doing what the team assumed: the Astro site's global stylesheet was imported in the layout and so shipped whole to every page, and the Eleventy site was still serving an unminified legacy script from a passthrough copy.

Two further habits help on every generator. Review the dependency list for packages that exist only to add a small visual effect — a scroll-reveal library, a smooth-scroll polyfill, a tooltip package — since CSS now covers most of them natively with scroll-behavior, @starting-style, animation-timeline and the Popover API. And audit third-party CSS loaded by embeds and widgets, which is invisible in your own build output but shows up in the Coverage panel on the live page; Auditing Third-Party Scripts with Lighthouse covers that side.

Budgets and Measurement

Asset optimization regresses quietly: one new dependency, one component library upgrade, one extra font weight. Put numbers on it. Both sites enforce per-template budgets in CI with Lighthouse CI's budgets.json — CSS 15 KB, JavaScript 50 KB on docs templates, compressed — and track compressed bundle size per pull request, as described in Tracking Bundle Size per Pull Request and Per-Template Performance Budgets.

Measure in three places. The build output tells you what you ship (sum of compressed file sizes per template). The Lighthouse network table and DevTools Coverage tell you what loads and what goes unused. Field RUM tells you whether it mattered — on the Astro site, the CSS and JavaScript work together moved LCP p75 from 2.4 s to 1.9 s and INP p75 from 180 ms to 110 ms.

Measured Impact

MeasureAstro site beforeAstro site afterEleventy site beforeEleventy site after
CSS, compressed31 KB9 KB14 KB11 KB (inlined)
JS on first load, compressed142 KB44 KB23 KB9 KB
Render-blocking requests2020
FCP, lab (slow 4G)2.2 s1.4 s1.9 s1.3 s
LCP p75, field2.4 s1.9 s1.6 s1.2 s
INP p75, field180 ms110 ms64 ms58 ms

Common Pitfalls

  • Purging runtime classes. Classes added by JavaScript or from CMS content disappear unless safelisted.
  • Inlining a large stylesheet. Every page pays for it again; above roughly 15 KB compressed, prefer a cached file.
  • Preloading everything. Modulepreload for chunks used only after interaction competes with the LCP image.
  • Heavy imports in shared layouts. One top-level import in a header component lands on every page.
  • Precompressed files without correct headers. Serving .br files without Content-Encoding: br breaks the page; let the host's precompressed mechanism do it.
  • Minifying <pre> whitespace. Collapsing whitespace inside code blocks ruins them.

Key Takeaways

  • Delete before you optimize: unused CSS and unneeded JavaScript are the biggest wins.
  • Remove render-blocking CSS requests by inlining small stylesheets or critical rules.
  • Load JavaScript per route and on interaction with dynamic import().
  • Minify everything, but expect most of the byte savings from compression.
  • Precompress at maximum levels at build time where the host can serve the variants.
  • Use inline SVG or a sprite for icons; retire icon fonts.
  • Enforce per-template budgets in CI so the gains stay.
  • Measure the built output and the field, not the framework's documentation: defaults are a starting point, and both sites here had "built-in" optimizations that were not doing what the team assumed until the numbers showed it.

FAQ

What is the biggest CSS win on a typical static site?

Usually removing unused CSS and making sure the remaining stylesheet does not block rendering for longer than necessary. On sites built with utility frameworks or large component libraries, unused rules are often more than 80 percent of the stylesheet. After that, inlining the small critical portion can cut first render further on slow connections.

Should a static site inline all of its CSS?

Only if it is small. Inlining a stylesheet under roughly 15 KB compressed removes a render-blocking request, but every page then carries it and it cannot be cached separately. Larger stylesheets are better served as one cached file, optionally with critical rules inlined.

Is minification still worth doing when responses are compressed?

Yes, but the gain is smaller than it looks. Minification typically removes 15 to 30 percent of uncompressed size, and after brotli the difference shrinks to 5 to 15 percent. It also reduces parse time slightly. It is cheap to add, so keep it, but do not expect it to fix a heavy page.

Why precompress assets if the CDN compresses on the fly?

CDNs compress at moderate levels to keep latency low. Precompressing at build time with brotli level 11 or zstd at a high level produces files around 15 to 20 percent smaller than on-the-fly compression, at no request-time cost, when the host can serve the precompressed variants.

How much JavaScript should a content page ship?

As little as the interactive features require. Many documentation and blog pages need none beyond a small theme toggle and search loader, and a budget of 50 KB compressed for all JavaScript on content templates is realistic for most static sites.