Unused JavaScript Statistics: Coverage Data From 218 Sites
Unused JavaScript statistics from 218 real homepages: the median site leaves 56% of its JS and 87% of its CSS unused on load. Methodology and per-file script.
The median website leaves 55.9% of its JavaScript and 87.4% of its CSS unused during page load. We measured this with Chrome DevTools Protocol coverage on 218 real homepages in October 2026. Only 48 of 218 sites (22.0%) used more than half of the JavaScript they downloaded. The problem doesn't depend on site size: small and large bundles both waste about half.
Coverage numbers are easy to misread, so this study states exactly what was measured and how. It covers the sample, the CDP precise coverage method, the distribution across sites, whether third-party scripts or your own bundles waste more, and what "unused" does and doesn't mean. The post ends with the Python script we used, so you can run the same measurement on your own pages.
Methodology: How We Measured Unused Code
We drew 420 domains at random from Tranco list 94GG2 (ranks 1,001–50,000) with a fixed seed, so anyone can reproduce the sample. Each homepage was loaded once in headless Chromium at 1366×900 on 3 October 2026. We kept the 219 that served a real page: no bot challenge, no DNS or TLS failure, at least 40 words of text. One of those loaded no JavaScript at all, which leaves 218 sites in the JS figures.
Before navigation we enabled three DevTools Protocol domains:
| Signal | CDP call | What it measures |
|---|---|---|
| JavaScript coverage | Profiler.startPreciseCoverage (detailed: true) | Which byte ranges of each script executed at least once |
| CSS coverage | CSS.startRuleUsageTracking | Which rules in each stylesheet matched an element |
| Script size | Debugger.scriptParsed → length | Uncompressed source length of every script |
Coverage was collected at the load event plus 3 seconds of idle time, with no clicks, scrolling or typing. That matches how a crawler or a lab speed test sees the page, and it's the same window the Chrome DevTools Coverage panel shows when you reload with recording on.
⚠️ "Unused on load" is not "dead code". A checkout validator, a modal, or a menu's open-state JavaScript sits idle until someone interacts with the page. The numbers below measure code shipped before it was needed. That code is a candidate for deferring, and only sometimes for deleting.
Byte counts are uncompressed source length, not transfer size. Compression shrinks what crosses the network, but the browser still parses and compiles the full uncompressed source. That parse and compile work is what blocks the main thread.
The Results: Half of Shipped JavaScript Never Runs on Load
| Metric (218 sites with JavaScript) | Result |
|---|---|
| Median share of JS unused on load | 55.9% |
| 25th / 75th / 90th percentile | 50.6% / 61.3% / 67.2% |
| Sites with more than 50% of JS unused | 78.0% |
| Sites with more than 70% of JS unused | 5.5% |
| Median JavaScript per homepage (uncompressed) | ≈3.0 MB (3,026 KB) |
| Median unused JavaScript per homepage | ≈1.6 MB (1,610 KB) |
| 90th percentile JavaScript per homepage | ≈8.6 MB (8,807 KB) |
| Median script files per homepage | 33 |
| Median files with more than 40% unused | 16 |
| Median share of CSS unused on load | 87.4% (IQR 79.6–93.2%) |
| Median CSS per homepage (uncompressed) | 352 KB |
The distribution is strikingly narrow. Half the sites fall between 50.6% and 61.3% unused. Almost no site is lean, and almost no site is extreme. Unused JavaScript isn't the result of a few badly built websites. It's the default outcome of shipping today's frameworks, tag managers and widgets without code-splitting.
Does a bigger bundle mean more waste?
Not really. We bucketed sites by total JavaScript size:
| Total JS per homepage | Sites | Median unused |
|---|---|---|
| Under 250 KB | 17 | 47.5% |
| 250 KB – 1 MB | 21 | 56.0% |
| 1 – 3 MB | 70 | 57.6% |
| Over 3 MB | 110 | 55.0% |
Only the smallest bucket does noticeably better, and even those sites leave almost half their JavaScript unused. Halving the waste on a 3 MB page saves far more absolute bytes than on a 200 KB page. The percentage, though, stays stubbornly around 55% at every size.
Third-party scripts vs your own code
91.3% of sites loaded JavaScript from another domain, and the median site took 77.5% of its JavaScript bytes from domains other than its own. That covers analytics, tag managers, chat widgets, ad tech and A/B testing, as well as the site's own CDN hostname when it differs from the main domain.
| Origin of the script | Median unused on load |
|---|---|
| Same domain as the page ("first-party") | 59.0% |
| Other domains ("cross-domain") | 54.4% |
The first-party number surprises most people. Your own bundles waste slightly more than the third-party code you blame. Third-party scripts dominate by volume, so they're the bigger lever in absolute bytes. Your own framework bundle is the part you can fix this sprint without negotiating with a vendor.
💡 Takeaway: Audit both. Start with the largest single files in the per-file report. On most sites, three or four files account for the majority of unused bytes.
CSS is worse, and that's expected
A median 87.4% of CSS went unused on load. Most stylesheets hold rules for every page template, every breakpoint and every UI state. The homepage only matches a small slice. Unused CSS doesn't run on the main thread the way JavaScript does, but a render-blocking stylesheet delays first paint until it has fully downloaded. We cover that cost in our guide to render-blocking resources.
Why Unused JavaScript Costs More Than Its Download Size
Every script the browser receives must be parsed and compiled before any of it can run, whether or not its functions are called. On a mid-range phone that work happens on the main thread, the same thread that handles taps, scrolling and rendering.
That's how unused code reaches your Core Web Vitals:
- LCP slips when render-blocking or early-executing bundles delay the main content.
- Total Blocking Time grows with every long task over 50 ms spent compiling and evaluating code.
- INP suffers when a tap lands while the main thread is still busy with startup scripts. Our explainer on Interaction to Next Paint covers the mechanics.
Modern engines compile lazily, so unused functions are cheaper than used ones. They still have to be downloaded, decompressed and pre-parsed, and module-level code in every imported file runs immediately. Deferring a 400 KB chat widget until a user clicks "Help" removes all of that cost from the critical path.
Measure It Yourself: A Per-File Coverage Script
This script uses the same CDP calls as the study. It prints the 25 files with the most unused bytes, marks each as first-party (1P) or third-party (3P), and totals JavaScript and CSS.
#!/usr/bin/env python3
"""Per-file unused JavaScript and CSS on page load, via Chrome DevTools Protocol coverage.
Usage: pip install playwright && playwright install chromium
python3 unused_code_report.py https://example.com --settle 3000
"""
import argparse, asyncio
from urllib.parse import urlsplit
from playwright.async_api import async_playwright
def js_unused(length, functions):
used = bytearray(length)
ranges = sorted((r for f in functions for r in f["ranges"]),
key=lambda r: (r["startOffset"], -r["endOffset"]))
for r in ranges: # outer ranges first, nested ranges override
s, e = max(0, r["startOffset"]), min(length, r["endOffset"])
if e > s:
used[s:e] = (b"\x01" if r["count"] else b"\x00") * (e - s)
return used.count(0)
def merged_len(intervals):
total, cs, ce = 0, -1, -1
for s, e in sorted(intervals):
if s > ce:
total += max(0, ce - cs)
cs, ce = s, e
else:
ce = max(ce, e)
return total + max(0, ce - cs)
async def main(url, settle):
async with async_playwright() as pw:
browser = await pw.chromium.launch()
page = await browser.new_page()
cdp = await page.context.new_cdp_session(page)
scripts, sheets = {}, {}
cdp.on("Debugger.scriptParsed", lambda p: scripts.__setitem__(p["scriptId"], (p["url"], p.get("length", 0))))
cdp.on("CSS.styleSheetAdded", lambda p: sheets.__setitem__(
p["header"]["styleSheetId"], (p["header"].get("sourceURL") or "inline <style>", p["header"].get("length", 0))))
for cmd in ("Debugger.enable", "Profiler.enable", "DOM.enable", "CSS.enable"):
await cdp.send(cmd)
await cdp.send("Profiler.startPreciseCoverage", {"callCount": False, "detailed": True})
await cdp.send("CSS.startRuleUsageTracking")
await page.goto(url, wait_until="load")
await page.wait_for_timeout(settle)
js = (await cdp.send("Profiler.takePreciseCoverage"))["result"]
css = (await cdp.send("CSS.stopRuleUsageTracking"))["ruleUsage"]
await browser.close()
host = urlsplit(url).hostname
rows = []
for entry in js:
src, length = scripts.get(entry["scriptId"], (entry["url"], 0))
if src.startswith("http") and length:
rows.append(("JS", src, length, js_unused(length, entry["functions"])))
used_css = {}
for r in css:
if r["used"]:
used_css.setdefault(r["styleSheetId"], []).append((r["startOffset"], r["endOffset"]))
for sid, (src, length) in sheets.items():
if length:
rows.append(("CSS", src, length, length - min(length, merged_len(used_css.get(sid, [])))))
rows.sort(key=lambda r: -r[3])
print(f"{'type':4} {'unused KB':>9} {'total KB':>9} {'unused':>7} origin file")
for kind, src, length, unused in rows[:25]:
origin = "1P" if (urlsplit(src).hostname or host) == host else "3P"
print(f"{kind:4} {unused / 1024:9.1f} {length / 1024:9.1f} {unused / length:7.0%} {origin:6} {src[:80]}")
for kind in ("JS", "CSS"):
t = sum(r[2] for r in rows if r[0] == kind); u = sum(r[3] for r in rows if r[0] == kind)
if t:
print(f"\n{kind}: {u / 1024:.0f} KB of {t / 1024:.0f} KB unused on load ({u / t:.0%}), uncompressed source")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("url")
ap.add_argument("--settle", type=int, default=3000, help="ms to wait after load")
a = ap.parse_args()
asyncio.run(main(a.url, a.settle))The js_unused function matters. V8 reports nested block ranges: a function range with a call count, and inner ranges for branches that never ran. Sorting outer ranges first and letting inner ranges overwrite them gives a byte-accurate used/unused map. Simply summing ranges double-counts.
We ran it on our own homepage first. The result wasn't flattering:
type unused KB total KB unused origin file
CSS 245.5 291.0 84% 1P https://bugviso.com/assets/index-G5K5JKyV.css
JS 144.3 219.8 66% 1P https://bugviso.com/assets/vendor-react-D-AzVPP4.js
JS 53.2 119.7 44% 1P https://bugviso.com/assets/index-DOKq2miA.js
JS 4.1 35.1 12% 1P https://bugviso.com/assets/vendor-icons-Bzh7tMvx.js
JS: 207 KB of 434 KB unused on load (48%), uncompressed source
CSS: 246 KB of 291 KB unused on load (84%), uncompressed source48% unused JavaScript, a little better than the median site but nowhere near clean. The React vendor chunk is the biggest single offender, which is typical for any single-page app: the framework runtime contains far more code than one page exercises.
What to Do With the Results
Treat the per-file report as a triage list, not a to-do list. Each file belongs in one of four buckets:
| Pattern in the report | Typical cause | Fix |
|---|---|---|
| Large 3P file, >60% unused | Chat, A/B testing, heatmaps, social embeds | Load on interaction or after requestIdleCallback |
| Large 1P vendor chunk, >50% unused | Whole framework or UI kit in one bundle | Route-level code-splitting, import() per route |
| Many small files, each mostly used | Healthy code-splitting | Leave alone |
| Global CSS file, >80% unused | One stylesheet for every template | Critical CSS inline, rest per-route or media-scoped |
For third-party widgets, the biggest win is usually interaction-triggered loading:
// ❌ Before: chat widget loads (and parses ~400 KB) on every page view
<script src="https://widget.example-chat.com/loader.js" async></script>
// ✅ After: load only when the visitor asks for help
document.querySelector('#help-button').addEventListener('click', () => {
const s = document.createElement('script')
s.src = 'https://widget.example-chat.com/loader.js'
s.onload = () => window.ExampleChat.open()
document.head.appendChild(s)
}, { once: true })For your own bundles, split by route with dynamic import() so each page downloads only what it renders:
// ✅ Heavy, below-the-fold or interaction-only components become separate chunks
const PricingCalculator = lazy(() => import('./PricingCalculator'))
const VideoModal = lazy(() => import('./VideoModal'))Then make sure the bundler can actually drop what you don't import. Our guide to JavaScript tree-shaking and dead-code elimination covers sideEffects, ES-module imports and the barrel-file trap. For the step-by-step removal workflow, see how to remove unused JavaScript and CSS.
How BugViso Measures Unused Code on Every Scan
BugViso's Advanced Speed & Performance Simulation Engine runs the same two DevTools Protocol measurements used in this study on every page it audits. Precise JS coverage and CSS rule-usage tracking report the percentage of unused bytes in the initial bundles, and any file over 40% unused is flagged individually. You therefore get the per-file view, not just a site-wide percentage.
The engine also captures Long Tasks over 50 ms with PerformanceObserver, computes Total Blocking Time, and attributes the worst task to its source script. That connects "this file is 70% unused" with "this file blocks the main thread for 380 ms". The page is also re-loaded under Slow 3G and Fast 3G throttling with CPU slowdown, which shows how much that unused code costs on a mid-range phone.
Each finding comes with a copy-paste fix in the remediation playbook, such as a dynamic-import pattern or a deferred loader, inside both the web report and the PDF. To see the per-file coverage for your own homepage, run a BugViso scan.
Limitations of This Study
- One load, one location, desktop viewport. Sites that geo-target or serve different bundles to mobile may differ. We didn't test a mobile user agent.
- No interaction. Code that runs on scroll, click or input counts as unused. That's intentional (it measures the startup cost), but it overstates true dead code.
- "Cross-domain" isn't always "third-party". A site's own CDN hostname (
static.example-cdn.net) counts as cross-domain in our split. - Bot-protected sites are excluded. 40 of the 420 sampled sites showed a challenge page and couldn't be measured. Large e-commerce and media sites are overrepresented among them.
- Uncompressed bytes. Transfer sizes are several times smaller with gzip or Brotli; parse and compile cost tracks the uncompressed figure.
FAQ
What percentage of unused JavaScript is normal?
In our sample of 218 homepages, the median was 55.9%, and three-quarters of sites were above 50.6%. Below 40% is genuinely good. Above 65% (the worst 10–15% of sites) usually points to an unsplit framework bundle or several heavy third-party widgets.
Is unused JavaScript the same as dead code?
No. Dead code can never run. "Unused on load" includes code that runs later: menus, modals, form validation, checkout. The fix for dead code is deletion. The fix for code you need later is deferring it with dynamic import() or interaction-triggered loading.
Does unused CSS hurt performance as much as unused JavaScript?
Less per byte. CSS doesn't run on the main thread like scripts do. But stylesheets in the <head> are render-blocking, so a large global stylesheet delays first paint until it has fully downloaded. Inline the critical CSS and load the rest without blocking render.
Why do third-party scripts take up so much of the JavaScript?
Tag managers, analytics, consent platforms, chat, A/B testing and ad tech each ship their own runtime. In our data, the median site took 77.5% of its JavaScript bytes from other domains. Our analysis of how Google Tag Manager affects main-thread performance shows how quickly tags add up.
How do I check unused JavaScript without writing code?
Open Chrome DevTools, press Ctrl+Shift+P (Cmd+Shift+P on macOS), run Show Coverage, and click the reload button in the Coverage panel. The red portion of each bar is code that didn't execute. Use the script above when you need to compare pages or track the number over time.
Conclusion
Half of the JavaScript the average site ships does nothing during load, and the waste looks the same whether the bundle is 300 KB or 3 MB. That makes it a process problem, not a size problem: per-file coverage, interaction-triggered third parties and route-level splitting fix it, and a BugViso performance audit shows exactly which files to start with.
See where your site stands
Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.