Fix Unused JavaScript for Better LCP
The average website ships 400KB of JavaScript, but only 100-200KB executes during page load. That unused code still downloads, parses, and compiles—wasting time before your LCP can paint.
What's the Problem?
Lighthouse flags scripts with more than 20KB of unused bytes. This happens when you bundle code for features the current page doesn't need: routes the user hasn't visited, components that only appear on interaction, or library functions you never call.
Modern bundlers make it easy to ship everything in one file. Convenient for developers, expensive for users. A visitor to your homepage downloads JavaScript for your checkout flow, admin panel, and every product page—even though they'll likely never see most of it.
Why it hurts LCP: JavaScript is render-blocking by default and expensive even when deferred. The browser must download, parse, and compile all JavaScript before it can execute. Parse and compile time scales with file size regardless of whether the code runs. On mobile devices, parsing 1MB of JavaScript can take 2-4 seconds of main thread time.
How to Identify This Issue
Lighthouse
Look for the audit "Reduce unused JavaScript" under Opportunities. It shows:
- Each script URL with unused bytes
- Total size vs wasted bytes
- Wasted percentage per file
- Sub-items showing which source files contribute most waste (if source maps available)
Lighthouse ignores scripts with less than 20KB unused—below that threshold, the overhead of splitting isn't worth it.
Chrome DevTools Coverage Tab
The Coverage tab shows exactly which bytes execute during page load.
- Open DevTools → More tools → Coverage (or Ctrl+Shift+P → "Show Coverage")
- Click the reload button to start recording
- The page reloads and coverage data populates
Reading the results:
- Red bars = unused bytes (downloaded but never executed)
- Blue/green bars = used bytes (actually executed)
- Click any file to see line-by-line coverage in Sources panel
Pro workflow:
- Record coverage on page load
- Interact with the page (open modals, navigate, etc.)
- Compare how coverage changes—code that turns blue on interaction is a candidate for lazy loading
Bundle Analyzer Tools
Visualize what's in your bundles:
npx webpack-bundle-analyzer stats.json
npx vite-bundle-visualizer
npx rollup-plugin-visualizer
Look for:
- Large node_modules you don't recognize
- Duplicate dependencies
- Entire libraries when you only use one function
The Fix
1. Route-Based Code Splitting
Split your bundle by route so each page loads only what it needs.
React Router:
import { lazy, Suspense } from 'react'
import { Route, Routes } from 'react-router-dom'
// Dynamic imports create separate chunks
const Home = lazy(() => import('./pages/Home'))
const Product = lazy(() => import('./pages/Product'))
const Checkout = lazy(() => import('./pages/Checkout'))
function App() {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/product/:id" element={<Product />} />
<Route path="/checkout" element={<Checkout />} />
</Routes>
</Suspense>
)
}
Vue Router:
// router.js
const routes = [
{
path: '/',
component: () => import('./views/Home.vue')
},
{
path: '/product/:id',
component: () => import('./views/Product.vue')
},
{
path: '/checkout',
component: () => import('./views/Checkout.vue')
}
]
2. Component-Level Code Splitting
Lazy load heavy components that don't appear immediately.
// React
const HeavyChart = lazy(() => import('./HeavyChart'))
function Dashboard() {
const [showChart, setShowChart] = useState(false)
return (
<div>
<button onClick={() => setShowChart(true)}>Show Analytics</button>
{showChart && (
<Suspense fallback={<Spinner />}>
<HeavyChart />
</Suspense>
)}
</div>
)
}
<!-- Vue -->
<script setup>
import { defineAsyncComponent, ref } from 'vue'
const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'))
const showChart = ref(false)
</script>
<template>
<button @click="showChart = true">
Show Analytics
</button>
<HeavyChart v-if="showChart" />
</template>
3. Tree Shaking Optimization
Tree shaking removes unused exports, but only works with ES modules.
Enable ES modules in your library imports:
// Bad: imports entire library
import _ from 'lodash'
_.debounce(fn, 300)
// Good: imports only what you use
import debounce from 'lodash/debounce'
debounce(fn, 300)
// Best: use lodash-es for full tree shaking
import { debounce } from 'lodash-es'
Verify tree shaking works in your bundler:
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
manualChunks: undefined // Let Rollup tree shake freely
}
}
}
}
// webpack.config.js
module.exports = {
mode: 'production',
optimization: {
usedExports: true, // Mark unused exports
sideEffects: true, // Enable tree shaking
}
}
Mark your package as side-effect free:
// package.json
{
"sideEffects": false
}
Or specify which files have side effects:
{
"sideEffects": ["*.css", "./src/polyfills.js"]
}
4. Replace Heavy Dependencies
Swap large libraries for lighter alternatives.
| Heavy Library | Size | Lighter Alternative | Size |
|---|---|---|---|
| moment | 300KB | date-fns (tree-shakeable) | 13KB used |
| moment | 300KB | dayjs | 2KB |
| lodash | 70KB | lodash-es (tree-shakeable) | varies |
| axios | 13KB | native fetch | 0KB |
| jQuery | 90KB | native DOM | 0KB |
| chart.js | 200KB | uPlot | 35KB |
// After: dayjs (2KB)
import dayjs from 'dayjs'
// Before: moment (300KB)
import moment from 'moment'
moment().format('YYYY-MM-DD')
dayjs().format('YYYY-MM-DD')
5. Analyze and Remove Dead Code
Use coverage data to find and eliminate unused code.
npx ts-prune
# src/utils/legacy.ts:15 - unusedFunction
# src/components/OldButton.tsx:1 - default
Review and delete files that Coverage shows as 100% unused.
Why This Works
Less JavaScript means:
- Faster download: Fewer bytes to transfer over the network
- Faster parse: The JavaScript engine processes less code
- Faster compile: JIT compilation has less work to do
- Less memory: Smaller heap allocation
Parse and compile time particularly impact mobile devices. V8 (Chrome's engine) can parse JavaScript at roughly 1MB/second on a mid-range phone. A 500KB bundle takes 500ms of main thread time just to parse—before any code executes.
Framework-Specific Solutions
import dynamic from 'next/dynamic'
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
loading: () => <Spinner />,
ssr: false // Skip server rendering for client-only components
})
ANALYZE=true npm run build
next.config.js:const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({})
<script setup>
// Lazy-loaded component (automatic code splitting)
const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'))
</script>
Lazy prefix for automatic async loading:<template>
<!-- Automatically lazy-loaded -->
<LazyHeavyChart v-if="showChart" />
</template>
npx nuxi analyze
// Async component with loading state
const AsyncComponent = defineAsyncComponent({
loader: () => import('./HeavyComponent.vue'),
loadingComponent: LoadingSpinner,
delay: 200,
errorComponent: ErrorDisplay
})
Verify the Fix
After implementing:
- Run Lighthouse → "Reduce unused JavaScript" should show lower wasted bytes
- Open Coverage tab → red bars should be significantly reduced
- Check Network tab → main bundle should be smaller, with additional chunks loading on navigation
- Test user flows → lazy-loaded content should appear without noticeable delay
Targets:
- Main bundle: < 100KB compressed
- Route chunks: < 50KB each
- Unused bytes: < 20% of total JavaScript
Quick validation:
Total JS: 500KB (340KB unused)
Initial JS: 150KB (30KB unused)
Route chunks: load on navigation
Common Mistakes
- Splitting too aggressively — Creating dozens of tiny chunks adds HTTP overhead. Aim for 5-15 chunks, not 100.
- Forgetting to preload critical chunks — If a lazy component appears above the fold, preload it
- Breaking shared dependencies — Code that's used across routes should be in a shared chunk, not duplicated
- Ignoring third-party bundles — Heavy npm packages often need replacement, not just splitting
- Not testing the loading experience — Lazy-loaded content needs loading states to avoid layout shift
Related Issues
Often appears alongside:
- Unminified JavaScript — Minify after splitting for maximum reduction
- Total Byte Weight — Unused JS contributes to overall page weight
- Render-Blocking Resources — Split render-blocking scripts too
Test Your Entire Site
Different pages have different JavaScript requirements—your homepage might be optimized while product pages load unused checkout code. Unlighthouse analyzes every page and shows exactly which routes have the most unused JavaScript.
Related
Unminified JavaScript
How to minify JavaScript files to reduce payload sizes, improve parse time, and accelerate your Largest Contentful Paint.
Render-Blocking Resources
Eliminate render-blocking CSS and JavaScript to improve LCP. Learn async/defer scripts, critical CSS extraction, and font loading strategies.