Fix Unused JavaScript for Better LCP

How to reduce unused JavaScript through code splitting, tree shaking, and dynamic imports to improve your Largest Contentful Paint.
Harlan WiltonHarlan Wilton6 min read Published

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.

  1. Open DevTools → More tools → Coverage (or Ctrl+Shift+P → "Show Coverage")
  2. Click the reload button to start recording
  3. 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:

  1. Record coverage on page load
  2. Interact with the page (open modals, navigate, etc.)
  3. 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 LibrarySizeLighter AlternativeSize
moment300KBdate-fns (tree-shakeable)13KB used
moment300KBdayjs2KB
lodash70KBlodash-es (tree-shakeable)varies
axios13KBnative fetch0KB
jQuery90KBnative DOM0KB
chart.js200KBuPlot35KB
// 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.

LCPLargest Contentful Paint CWV
25% weight
Good ≤2.5sPoor >4.0s

Framework-Specific Solutions

Next.jsNext.js automatically code-splits by route. For component-level splitting:
import dynamic from 'next/dynamic'

const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
  loading: () => <Spinner />,
  ssr: false // Skip server rendering for client-only components
})
Analyze bundles with:
ANALYZE=true npm run build
Enable in next.config.js:
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})

module.exports = withBundleAnalyzer({})
NuxtNuxt 3 code-splits routes automatically. For components:
<script setup>
// Lazy-loaded component (automatic code splitting)
const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'))
</script>
Or use the Lazy prefix for automatic async loading:
<template>
  <!-- Automatically lazy-loaded -->
  <LazyHeavyChart v-if="showChart" />
</template>
Analyze bundles:
npx nuxi analyze
Vue (Vite)Vue with Vite handles async components natively:
// Async component with loading state
const AsyncComponent = defineAsyncComponent({
  loader: () => import('./HeavyComponent.vue'),
  loadingComponent: LoadingSpinner,
  delay: 200,
  errorComponent: ErrorDisplay
})
For route-level splitting with Vue Router, use dynamic imports as shown above.

Verify the Fix

After implementing:

  1. Run Lighthouse → "Reduce unused JavaScript" should show lower wasted bytes
  2. Open Coverage tab → red bars should be significantly reduced
  3. Check Network tab → main bundle should be smaller, with additional chunks loading on navigation
  4. 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

Often appears alongside:

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.