NNicheStackTutorials
← All tutorials
SEO & Web Vitals

Remove Unused JS/CSS on Static Sites to Cut CLS and TBT

Purged 142KB CSS and 89KB JS from a VitePress docs site. CLS went from 0.19 to 0.02. Step-by-step with coverage tools.

·11 min read·SEO & Web Vitals
Remove Unused JS/CSS on Static Sites to Cut CLS and TBT cover

Site: VitePress tutorial site, default theme + custom CSS + Prism syntax theme. Lighthouse flags: 89KB unused JS, 142KB unused CSS, CLS 0.19 from late-loading stylesheet.

Find what's unused

Chrome DevTools → Coverage tab → reload. Sort by unused bytes.

My top offenders:

  • vitepress/theme-default/styles/vars.css — 60% unused (dark mode vars I don't use)
  • shiki theme bundle — loaded two themes, used one
  • Dead client:idle hydration on components never in viewport

CSS: split critical from deferred

/* critical.css — inlined in <head> */
:root { --vp-c-bg: #fff; --vp-font-family-base: 'Inter', sans-serif; }
.VPContent { max-width: 960px; margin: 0 auto; }
.ad-slot { min-height: 250px; }
<link rel="preload" href="/assets/deferred.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/assets/deferred.css"></noscript>

Late CSS injection causes FOUC and CLS if it changes layout. Reserve ad slots in critical CSS.

VitePress: trim Shiki

.vitepress/config.ts:

export default defineConfig({
  markdown: {
    theme: { light: 'github-light', dark: 'github-light' }, // single theme
  },
  vite: {
    build: {
      cssCodeSplit: true,
      rollupOptions: {
        output: {
          manualChunks(id) {
            if (id.includes('shiki')) return 'shiki';
          },
        },
      },
    },
  },
});

Disabling dark mode removed 40KB CSS variables.

Purge unused Tailwind / custom CSS

If using Tailwind:

// tailwind.config.js
content: ['./.vitepress/**/*.{vue,ts,js}', './**/*.md'],

Custom CSS — manual audit:

npx purgecss --css dist/assets/*.css --content dist/**/*.html --output dist/purged/

Review diff before shipping — purge can be aggressive.

JS: audit imports

Bad:

import _ from 'lodash'; // entire library for .debounce

Good:

import debounce from 'lodash/debounce';

VitePress search: if you use Algolia DocSearch, remove local fuse.js entirely.

Hugo-specific purge

{{ $css := resources.Match "css/**/*.css" | resources.Concat "bundle.css" | resources.Minify | resources.Fingerprint }}

Don't concat theme CSS you don't need — override head.html and drop unused <link> tags.

CLS connection

Unused CSS often includes rules that override ad slot dimensions after paint. When I purged a .content img { max-width: 100%; height: auto } duplicate, ad containers stopped resizing.

Fixed with:

.ad-slot ins { display: block !important; min-height: inherit; }

Before / after

CSS transfer: 186KB → 44KB
JS transfer:  124KB → 35KB
TBT:          420ms → 95ms
CLS:          0.19 → 0.02
Performance:  71 → 96

Ongoing: CI guard

# fail build if main chunk > 50KB
MAIN_SIZE=$(stat -f%z dist/assets/index*.js)
[ "$MAIN_SIZE" -gt 51200 ] && exit 1

Static sites shouldn't ship SPA-sized bundles. Treat every KB as an LCP tax.

Tags: CSS PurgeTree ShakingCLSTBTVitePress