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.
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:
- 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. - 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 ofcritters) compute this at build time per page. - 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 } }
});
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) | Before | After purge |
|---|---|---|
| CSS on docs pages (brotli) | 31 KB | 9 KB |
| CSS on docs pages (uncompressed) | 184 KB | 41 KB |
| Unused bytes (DevTools Coverage) | 76% | 18% |
| LCP p75, field | 2.4 s | 2.1 s |
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 });
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 "{}"'
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
| Measure | Astro site before | Astro site after | Eleventy site before | Eleventy site after |
|---|---|---|---|---|
| CSS, compressed | 31 KB | 9 KB | 14 KB | 11 KB (inlined) |
| JS on first load, compressed | 142 KB | 44 KB | 23 KB | 9 KB |
| Render-blocking requests | 2 | 0 | 2 | 0 |
| FCP, lab (slow 4G) | 2.2 s | 1.4 s | 1.9 s | 1.3 s |
| LCP p75, field | 2.4 s | 1.9 s | 1.6 s | 1.2 s |
| INP p75, field | 180 ms | 110 ms | 64 ms | 58 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
.brfiles withoutContent-Encoding: brbreaks 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.
Related
- Up: Performance Optimization & Core Web Vitals for SSGs — the whole performance picture.
- Inlining Critical CSS on Static Sites — removing render-blocking CSS.
- Purging Unused CSS with Tailwind and Lightning CSS — deleting unused rules safely.
- Code Splitting and Module Preload in Vite-Based SSGs — JavaScript per route.
- Minifying HTML, CSS and JS in Eleventy — safe minification settings.
- Precompressing Assets with Brotli and Zstd — maximum compression at build time.
- SVG Icon Sprites vs Inline Icons — icons without icon fonts.
- Eliminating Render-Blocking CSS on Static Sites — the LCP view of the same problem.
- JavaScript Hydration & Partial Rendering — how much JavaScript a page needs.
- Keeping Tailwind CSS Output Small on Static Sites — measure what makes Tailwind CSS grow on a static site, from broad sources and safelists to arbitrary values and theme bloat, and keep it under a budget.
- Fixing Unused JavaScript Warnings on Static Pages — trace Lighthouse's reduce unused JavaScript warning on a static site to its sources, from site-wide bundles to hydration and third parties, and fix each.
- Fixing Missing Tailwind Classes in Production Builds — fix styles that work locally but vanish in production with Tailwind: dynamic class names, unscanned files, Markdown content and safelists, plus a CI check.
- Tailwind v4 in Astro Without PostCSS Plugins — move an Astro site from Tailwind 3 with the PostCSS integration to Tailwind 4 on its Vite plugin: CSS-first config, sources, dark mode and build time, measured.