Fixing Unused JavaScript Warnings on Static Pages
A static page should need very little JavaScript, yet Lighthouse often reports "Reduce unused JavaScript — potential savings of 180 KiB" on one. The warning is a symptom, and on static sites it has a short list of causes: one bundle shared by every page, a framework hydrating content that never changes, a library imported whole for one function, and third-party scripts. Each needs a different fix, so the first job is to find which bytes are unused and where they came from.
This guide traces the warning on an Eleventy marketing and docs site whose pages shipped 214 KB of JavaScript (compressed) with 71% of it unused at load, and fixes it to 38 KB. It belongs to CSS and JavaScript Asset Optimization; the general approach to splitting is in Code Splitting and Module Preload in Vite-Based SSGs.
Step 1: Measure Coverage and Attribute It
Lighthouse's audit names scripts but not modules within them. Chrome DevTools' Coverage panel (Ctrl+Shift+P → "Show Coverage", then reload) shows unused bytes per file, and source maps attribute them to modules. For bundles built with esbuild, the metafile gives module sizes:
npx esbuild src/js/main.js --bundle --minify --metafile=meta.json --outfile=/dev/null
npx esbuild-visualizer --metadata meta.json --filename stats.html
On a typical docs page:
| Script | Size (brotli) | Unused at load | What it was |
|---|---|---|---|
main.js | 96 KB | 78% | Site-wide bundle: search, carousel, charts, tabs, copy |
vendor-analytics.js | 61 KB | 74% | Tag manager + two vendor scripts |
react-widget.js | 44 KB | 63% | Pricing calculator, hydrated on every page |
polyfills.js | 13 KB | 96% | Polyfills for browsers no longer supported |
Cause 1: One Bundle for Every Page
main.js imported every feature the site had: a search modal (with its index loader), a homepage carousel, a charting library for two benchmark pages, docs tabs, a copy button. Every page downloaded all of it. Splitting by feature, and loading each feature only when its markup exists, fixed most of it:
// src/js/main.js — now a 3 KB loader
const features = [
['[data-carousel]', () => import('./carousel.js')],
['[data-chart]', () => import('./charts.js')],
['[data-tabs]', () => import('./tabs.js')],
['pre', () => import('./copy-button.js')],
];
for (const [sel, load] of features) {
if (document.querySelector(sel)) load().then((m) => m.init());
}
document.querySelector('#search-button')?.addEventListener('click', () => import('./search.js').then((m) => m.open()), { once: true });
esbuild with --splitting --format=esm writes each dynamic import as its own chunk. A docs article now loads the loader, tabs and copy button — 9 KB — and the charting library's 41 KB loads only on the two benchmark pages. Search loads on click.
Cause 2: A Framework Component Hydrated Everywhere
The pricing calculator was a React component, mounted by a script included in the base layout so that it was "available" on any page that used it. Only three pages did. The script now lives in those pages' layout only, and it loads React when the calculator scrolls into view, using an IntersectionObserver around the dynamic import. Pages without the calculator ship no React at all. On a generator with islands, such as Astro's client:visible, this is built in; Deferring Hydration with client:visible in Astro shows the equivalent.
Cause 3: Polyfills for Browsers No Longer Supported
polyfills.js came from an old Babel setup targeting browsers that had a combined share of 0.2% in the site's analytics. esbuild targets es2020; the polyfill bundle was removed entirely. A <script type="module"> boundary means very old browsers simply get the static page without enhancements, which for a content site is a fine fallback.
Cause 4: Third-Party Scripts
The tag manager loaded two vendor scripts — a heatmap tool and an A/B testing library — on every page, plus the analytics library. The heatmap tool had not been looked at in a year and was removed. The A/B testing library ran only on the homepage experiment, so it moved into a homepage-only tag. That cut 32 KB; the remaining analytics was moved to load after first interaction or after 4 seconds, whichever came first, as in Loading Google Tag Manager Without Hurting INP. Lighthouse still lists part of the analytics script as unused, which is expected: much of it handles events that happen later.
Step 2: Lock It In With a Budget
Per-template JavaScript budgets in Lighthouse CI fail a pull request that pushes a page type over its limit:
{ "path": "/docs/*", "resourceSizes": [{ "resourceType": "script", "budget": 50 }] }
The docs budget is 50 KB, with 12 KB of headroom over the current 38 KB. Per-Template Performance Budgets covers the setup. The budget caught a regression two weeks later, when a new "feedback" widget imported a whole date library for one formatting call.
Reading the Coverage Numbers Correctly
The Coverage panel's "unused" figure needs interpretation. It counts bytes that did not execute between page load and the moment recording stopped, so stopping right after load overstates unused code, and scrolling and clicking around the page before stopping lowers the figure. For decisions, the useful question is not "how much is unused" but "what is this code, and should it load now?" Code for a menu that opens on click is fine to load eagerly if it is 2 KB, and worth deferring if it is 40 KB. Code for a feature the page does not have should never load. Code for a feature lower on the page should load when the reader gets near it.
The audit also ignores scripts with less than 20 KB unused, so a page with many small scripts can show no warning while still loading a lot of code that does nothing. Checking total JavaScript per template against a budget, rather than chasing the warning, catches both cases. After the fixes on this site, Lighthouse no longer listed the audit at all, but the budget is what keeps the number down.
Measured Impact
| Measure (docs article) | Before | After | Tool |
|---|---|---|---|
| JavaScript transferred | 214 KB | 38 KB | DevTools |
| Unused at load | 152 KB (71%) | 7 KB (18%) | Coverage panel |
| Lighthouse "unused JavaScript" savings | 180 KiB | not listed | Lighthouse 12 |
| Total Blocking Time, mobile | 410 ms | 60 ms | Lighthouse, median of 5 |
| LCP, mobile lab | 2.4 s | 1.7 s | Lighthouse, median of 5 |
| INP p75, field | 240 ms | 120 ms | CrUX, 28 days |
Pitfalls & Rollback
- Deleting "unused" code that runs on interaction. Check what it is first; load it on demand instead.
- One shared bundle for all pages. Split by feature and load by the presence of markup.
- Hydrating components in the base layout. Load them only on pages that use them.
- Forgetting third parties. They are often the largest share and the easiest to cut.
- Rollback: the loader can import everything eagerly again with one change; each feature module is unchanged.
Conclusion
"Reduce unused JavaScript" on a static page usually points at a shared bundle, a component hydrated everywhere, stale polyfills and third-party scripts. Splitting features and loading them only where their markup exists, scoping the React widget to its pages, dropping old polyfills and trimming tags took a docs article from 214 KB to 38 KB of JavaScript, halved field INP, and a per-template budget keeps it there.
FAQ
What does reduce unused JavaScript mean in Lighthouse?
Lighthouse records which bytes of each script executed during the page load, using code coverage, and reports scripts where a large share of bytes did not run. Unused bytes still had to be downloaded and parsed. The audit lists scripts with more than 20 kilobytes of unused code, so small scripts never appear even if mostly unused.
Is all unused JavaScript a problem?
No. Code for interactions, such as a menu that opens on click, runs only when the reader interacts, so it shows as unused during load even though it is needed. The problem is code for features the page does not have, or code loaded eagerly that could load on demand. Look at what the unused code is before removing it.
Why does a static page load JavaScript it does not use?
Usually because one bundle is shared across all pages, so every page loads code for features that exist on only some of them; because a framework hydrates the whole page; or because third-party scripts load large libraries. Splitting bundles per feature and loading them only where needed fixes most cases.
Does fixing unused JavaScript improve Core Web Vitals?
Mostly through INP and, on slow devices, LCP. Less JavaScript means less main-thread time during load, so early interactions respond faster. In this guide, removing 160 kilobytes of unused code cut Total Blocking Time from 410 to 60 milliseconds on a mid-range phone profile and field INP from 240 to 120 milliseconds.
Related
- Parent: CSS and JavaScript Asset Optimization — CSS and JS weight.
- Code Splitting and Module Preload in Vite-Based SSGs — splitting in Vite-based generators.
- How to Reduce Bundle Size in Eleventy Builds — bundling choices in Eleventy.
- Auditing Third-Party Scripts With Lighthouse — the third-party share.
- Tracking Bundle Size per Pull Request — catching growth early.
Filed underFixes