Lighthouse flags JavaScript bytes that are downloaded and parsed but never executed during the audit. Wasted JS hurts every metric: LCP, TBT, FCP, bandwidth, battery.
TL;DR
- What: Bytes of JS shipped to the browser that aren't used during the page lifecycle Lighthouse measured.
- Target: Reduce wasted bytes as low as practical. There's no single threshold, Lighthouse reports estimated savings in KB.
- Top three fixes: code-split by route, tree-shake imports, audit third-party scripts.
What this audit measures
Lighthouse loads your page, captures coverage data from Chrome's Coverage tool, and reports unused bytes per script. Each row shows: URL, total bytes, wasted bytes, wasted percentage.
A 200 KB bundle with 70% unused = 140 KB the user paid for and got nothing from.
Most common causes
- Large UI libraries imported wholesale when only a few components are used
- Polyfills shipped to modern browsers that don't need them
- Third-party scripts (Google Tag Manager containers, Intercom, full analytics SDKs), often the biggest offenders
- Duplicate dependencies across bundles
- Code in the main bundle that belongs in a route-specific chunk
Five concrete fixes
1. Code-split by route
Modern frameworks do this automatically. In Next.js, every page becomes its own chunk; in Astro, only "islands" that need hydration get JS at all.
For custom React apps:
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<Router>
<Route path="/dashboard" component={Dashboard} />
<Route path="/settings" component={Settings} />
</Router>
</Suspense>
);
}
2. Tree-shake your imports
// WRONG: pulls the entire lodash library (~70 KB)
import _ from 'lodash';
_.debounce(fn, 200);
// RIGHT: only the function you use (~2 KB)
import debounce from 'lodash/debounce';
debounce(fn, 200);
// EVEN RIGHTER: use the native equivalent or a tiny utility
const debounce = (fn, ms) => {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
};
3. Stop shipping polyfills to modern browsers
Use <script type="module"> for modern code and <script nomodule> for the polyfilled fallback, or set up dual builds in your bundler (webpack module/nomodule, esbuild --target=es2020).
4. Defer third-party scripts
Especially Google Tag Manager. The GTM container itself is small, but every tag inside it loads JS. Audit GTM tags ruthlessly, does this tag need to fire on page load, or can it wait for requestIdleCallback?
<!-- Defer GTM until after first paint -->
<script>
window.addEventListener('load', () => {
setTimeout(() => {
(function(w,d,s,l,i){...GTM snippet...})(window,document,'script','dataLayer','GTM-XXXXX');
}, 1500);
});
</script>
5. Find duplicate dependencies
Bundle analyzers reveal when two versions of the same library are shipped:
# webpack
npx webpack-bundle-analyzer dist/stats.json
# next.js
ANALYZE=true npm run build
Common culprit: react shipped twice because a third-party package bundled its own copy.
Verification
- Re-run Lighthouse. The audit will list specific URLs and remaining wasted bytes.
- Run Chrome DevTools → Coverage tab (Cmd+Shift+P → "Show Coverage") → reload the page. The red bars show unused bytes in real time.
- Track bundle sizes over time with
size-limitor similar in CI to prevent regressions.
Related audits
- Total Blocking Time (TBT), what unused JS often causes
- Render-blocking resources, JS in the critical path
- Largest Contentful Paint (LCP), also affected
Audit your URL at https://lighthouse-md.com.
Audit your page now
Paste your URL, get scores plus a CLAUDE.md plan for Claude Code.