
No team bloats a site on purpose. Bloat is what happens when decisions that each look reasonable on their own pile up: a countdown script added for a campaign, the code from an A/B test that ended long ago, a slider plugin installed for a single page, a second icon set the designer left behind, a live chat widget someone said to "keep for now." Each one was a five-minute decision when it went in. Removing it is nobody's job.
Every visitor pays for this buildup. The browser downloads, parses and processes CSS rules and JavaScript that will never run on that page. Cleaning up unused code is exactly the work of unpacking that baggage, and it comes down to two moves: don't ship the code at all, or defer it until the moment it is actually needed.
Here is the measurable side of the job: the Coverage panel in Chrome DevTools shows, line by line, how many bytes of each file are used and how many are not. Lighthouse reports the same thing through two separate audits, listing files that exceed a 2 KiB threshold for unused CSS and a 20 KiB threshold for unused JavaScript. Together, these two tools turn the vague feeling that "my site is heavy" into a concrete list of files.
Why code weight builds up unnoticed
The sources of unused code look very different from one another, but they all share the same structural cause: adding is easy, removing takes ownership.
- Theme and plugin defaults. Most themes and plugins load their own CSS and JavaScript across the entire site. The contact form plugin's styles download on product pages too, and the slider code runs on templates that have no slider.
- Framework bloat. Utility-first CSS frameworks and large component libraries define thousands of rules during development. If the production configuration is not set up correctly, every one of those rules goes live.
- Copy-paste libraries. A large library added to the page for a single date picker stays in the script tag long after that feature is gone.
- Dead marketing tags. The pixel from an ad account that was closed, the heatmap tool whose contract ended, a second analytics setup nobody looks at anymore. These usually sit inside a tag manager container, out of the development team's sight.
- Temporary fixes that become permanent. The small script written to paper over a bug stays on the page after the bug is fixed.
None of the items on this list is bad engineering. They are all the natural output of a system nobody keeps an inventory of. That is why the first step of a cleanup is not deleting but taking inventory.
Unused CSS and unused JavaScript do not cost the same
The two file types sit in the same folder, but they send the browser different bills. A cleanup done without understanding this difference ends up targeting the most visible item and skipping the most expensive one.
On the CSS side, the problem is render blocking. The browser cannot paint content before it builds the page's style model. That is why Lighthouse's render-blocking resources audit flags two patterns: script tags in the <head> without defer or async, and stylesheets that have no disabled attribute and no media value specifically matching the user's device. A media="all" value also counts as render blocking. Even if ninety percent of the rules in the file never match anything on that page, the browser reads all of them and processes them into the style model.
On the JavaScript side, the problem is the main thread. The work does not end once a script has downloaded: the browser parses the file to check its syntax, compiles it to bytecode and executes it. As the file grows, this work turns into long tasks on the main thread and delays the browser's response to user interactions. A page can look loaded without having finished loading; a user who clicks during that window feels the delay.
This cost also spills over into Largest Contentful Paint. Browsers render images on the main thread. So even when the largest image has fully downloaded, it may have to wait for an unrelated script to finish running before it appears on screen. For the full LCP chain, see our guide to getting LCP under 2.5 seconds.
The practical conclusion: compression does not solve this. Gzip or Brotli reduce the space a file takes up on the network; they do not reduce the parsing, compiling and style calculation the CPU has to do once the file is unpacked. A big file that downloads small is still a big file.
Measuring real usage with the Coverage panel
The Coverage panel is the most direct measurement tool for this, because it produces real execution data rather than estimates. To open it, open developer tools, bring up the command menu, type coverage and choose Show Coverage. The panel opens in the bottom drawer. Alternatively, use the Coverage entry under More tools in the more options menu at the top right.
When you start recording and reload the page, the table fills with these columns:
- URL: the address of the resource being inspected.
- Type: whether the resource contains CSS, JavaScript or both.
- Total Bytes: the resource's total size.
- Unused Bytes: the number of unused bytes.
- Usage Visualization: a visualization of those two values. The green part of the bar is used bytes, the gray part is unused bytes.
The status line at the bottom of the panel gives the percentage of used code, taking any applied filter into account. You can show only CSS or only JavaScript from the dropdown, or narrow the list by typing an address into the filter at the top. Clicking a row opens the resource line by line in the Sources panel, where unused lines are marked with vertical gray bars to the left of the line numbers. There is an Export coverage button for exporting the report.
There is a subtlety here that ruins most measurements. Coverage counts code as used only if it actually ran while recording was on. A menu that opens on hover, form validation that runs on click, an animation triggered by scrolling, code that only kicks in on errors: until you perform that interaction, all of it shows as "unused." Opening the page and looking at the report straight away makes code that must not be deleted look deletable.
The right way to use it: play out a real user journey while recording. Open the menus, click through the tabs, fill in the form incorrectly to trigger the warning, scroll to the bottom of the page. Then repeat the same measurement separately for each template type: home page, category, product or post detail, contact, cart. Base deletion decisions on the combination of these reports, not on a single page's report. Measuring in a clean browser profile with no extensions also makes the results noticeably cleaner.
Reading Lighthouse warnings correctly
Coverage gives you the raw truth; Lighthouse turns it into audits tied to thresholds. Five audits relate directly to this topic:
| Audit | When it warns |
|---|---|
| Remove unused CSS | In the Opportunities section, lists every stylesheet containing unused CSS with potential savings of 2 KiB or more. |
| Remove unused JavaScript | Flags every JavaScript file with more than 20 kibibytes of unused code. |
| Reduce the impact of third-party code | Fails when third-party code blocks the main thread for 250 ms or longer in total. |
| Lazy load third-party resources with facades | Fails when the page loads a deferrable embed (such as social button widgets or video player embeds). |
| Eliminate render-blocking resources | Flags script tags in the head without defer or async and stylesheets without a device-specific media value. |
When reading these audits, keep three things apart. First, the savings estimates in the opportunities list are not penalties; they are not negative points that lower your rankings but a breakdown of which bytes the page is carrying for nothing. Second, Lighthouse is a lab measurement of a single load on a single device, while the Core Web Vitals assessment comes from real user data; the two ask different questions. Third, and this is where most mistakes happen: "unused on this page" and "unused on the site" are not the same sentence. A stylesheet that does nothing on the home page may be essential on the checkout step.
Two levers for reducing CSS weight
The first lever is not shipping the rule at all. Tools like PurgeCSS scan your template files, find the selectors that are actually used and remove the rest from the production output. With utility-first frameworks, this step is not optional; the entire difference between development and production output depends on this scan. The heart of the configuration is the content list describing which files to scan:
module.exports = {
content: ['./src/**/*.{html,js,jsx,vue}', './templates/**/*.php'],
safelist: ['active', 'is-open', 'has-error']
};
The most common accident here involves dynamically generated class names. A class name assembled piece by piece in code never appears as full text anywhere in a static scan, so it gets removed. The result is a component that goes live without styles. State classes that JavaScript adds at runtime fall into the same trap. The fix has two parts: write class names out in full rather than assembling them from fragments, and protect runtime-added names with a safelist.
The second lever is moving the remaining CSS out of the render path. Styles needed only for printing do not hold up the first paint when they are marked with media="print". We cover the approach of separating the rules needed for the first screen and leaving the rest for later in a separate article: our guide to inlining critical CSS is the natural continuation of this section. Order matters: cleaning up unused rules first makes critical CSS extraction both smaller and more accurate.
Reducing JavaScript weight: tree shaking and code splitting
The same two-part logic applies on the JavaScript side; only the names of the tools change.
Tree shaking: stripping code you will not ship at build time
Tree shaking is the name for removing dead code from a bundle. The mechanism is simple: the module bundler looks at import and export statements to work out which modules are exported and which are actually imported, and leaves exports nobody uses out of the output. Bundlers such as Webpack and Rollup do this automatically when combining files.
The mechanism depends on static module syntax. Importing an entire library under a single name gives the bundler no chance to work out what is unused; pulling the function you need from its own module path links only that piece:
// gives the bundler no chance to tree shake
import _ from 'lodash';
// links only the module you need
import debounce from 'lodash/debounce';
The package author also needs to make a declaration. The sideEffects field in package.json tells the bundler that the module has no side effects and that unused exports can safely be dropped. If there are side-effectful entries such as CSS imports or polyfill files, they are listed individually in this field. The way to verify the result is a bundle analysis report: if a function you know is unused still shows up in the output, either the declaration is missing or a non-static import is being used.
Code splitting: deferring the code you have to ship
Code splitting means separating the code that is truly needed on first load from everything else and breaking the rest into separate chunks, instead of shipping one giant bundle. The goal is to send only the code the first route needs when the user opens the app, minimizing the amount of script that has to be parsed and compiled. Bundlers such as Webpack, Rollup and Parcel do this with dynamic imports.
There are two ways to apply it. Route-based splitting produces a separate chunk for each URL path; while the user is on the product listing, the blog page's code does not download. Component-based splitting targets individual pieces with known weight: charting libraries, rich text editors, map components, date pickers, video players. What they have in common is that they are needed in a small part of the page and often only after an interaction.
form.addEventListener('submit', async (e) => {
e.preventDefault();
const { validate } = await import('./heavy-validation.js');
validate(form);
});
In modern frameworks with file-system-based routing, route-based splitting is already the default; each page file is built as a separate chunk. But this automatic behavior is not enough on its own: if a page component imports a large library directly, that library becomes part of the page's chunk and the chunk bloats. At that point, additional splitting at the component level is needed.
Who actually needs code splitting?
It is worth being honest here, because on most sites code splitting is a solution aimed at the wrong target. Code splitting is a bundler technique and only makes sense if you have a bundle to split. If you build your site as a JavaScript application, produce a single large bundle, and users download the whole app on first load, this section is your top priority.
If, on the other hand, your site consists of classic server-rendered pages with a few independent script tags, there is no bundle to split. Your question is not "how do I split the bundle" but "which script loads on which template." Both questions serve the same goal, but their solutions are completely different and cannot stand in for each other.
The WordPress equivalent of code splitting: conditional loading and a plugin diet
In WordPress, the practical equivalent is not dynamic imports but deciding which asset gets enqueued on which template. Every plugin registers its own style and script files, and most do it without distinguishing between pages. The result is a CSS file spread across the whole site for a single contact form.
WordPress offers a direct way to handle this: wp_dequeue_style() removes a previously enqueued stylesheet from the queue, and wp_dequeue_script() does the same for scripts. Timing is the critical point. Your code has to run after the plugin's own registration, so the hook is attached with a late priority value:
add_action('wp_enqueue_scripts', function () {
if (!is_page('contact')) {
wp_dequeue_style('contact-form');
wp_dequeue_script('contact-form');
}
}, 100);
This approach has two known weak points. First, handle names can change between plugin versions; code targeting a renamed handle stops matching without throwing any error, and the weight quietly comes back. That is why conditional loading rules should be re-measured after plugin updates. Second, dequeuing does not stop the plugin. It keeps running on the server, generating queries and firing its hooks. If the feature is not used at all, the right move is not dequeuing but removing the plugin entirely.
Thinking about these two points together turns cleanup from a one-off operation into a recurring checklist item. In our on-page SEO analysis work, the code weight inventory is built the same way, template by template.
Third-party scripts: the most expensive and least owned weight
On most sites, all the effort spent shrinking your own code does not even cover the cost of a single third-party tag. Lighthouse uses a clear threshold here: any script hosted on a domain different from the audited URL's domain counts as third-party, and the audit fails if these scripts block the main thread for more than 250 ms in total.
The mechanism behind the problem is parser blocking. When the browser hits a script tag, it stops building the DOM, hands the script to the JavaScript engine and waits until it finishes running. The async and defer attributes change this behavior, but not in the same way:
- async: the browser downloads the script asynchronously while it keeps parsing the HTML. When the download finishes, parsing stops and the script runs. Suitable for scripts that need to run early, such as analytics.
- defer: the download is still asynchronous, but the script does not run until document parsing is complete. Suitable for resources that have nothing to do with the first visible part of the page.
Unless they are part of the critical rendering path, third-party scripts should always load with one of these two. In Blink-based browsers, these attributes also lower the priority of the network request, so the gain shows up not only at runtime but during download as well.
Embeds have their own solution. Lighthouse recognizes deferrable embeds (such as social button widgets and video player embeds) and recommends the facade approach for them. A facade is a lightweight placeholder that stands in for the real embed: a static preview sits on screen until the user clicks, and the real player or chat window loads only after the click. For a live chat window used by a small share of visitors, this moves the weight off everyone and onto only those who need it.
The harder part is not the technical fix but ownership. Tag manager containers are usually managed by marketing, and the development team does not see what was added when. The lasting solution is to keep an inventory where every tag in the container has an owner and a reason to exist. A tag with no owner is a tag to delete.
The order for cleaning up without breaking things
Done wrong, unused code cleanup produces failures far more expensive than any speed gain. This order keeps the risk low:
- Take inventory. List every resource the page loads in the Network panel, separate the third-party domains and have the relevant team confirm which business function each one serves.
- Measure. Record Coverage with a full user journey, separately for each template type. Note the Lighthouse audits.
- Start with high return and low risk. The usual order is: third-party tags with no remaining business function, facades for embeds, conditional loading of plugin assets, selector cleanup at build time, and code splitting last.
- Make one change at a time and test it on staging first. Shipping five changes together and then rolling back a failure costs more than the time you saved.
- Test for regressions functionally, not just visually. Key checkpoints: runtime state classes, form validation warnings, error and empty state screens, the logged-in user view, the mobile menu, the cookie notice and the checkout steps.
- Measure again and confirm in field data. Lab measurements show that the change works; real user data shows whether its impact is meaningful.
How much improvement to expect
The honest answer to this question is not a percentage, and it pays to be cautious with any source that gives one. The gain depends not on how much code you remove but on where that code sits in the bottleneck. The same 200 KB produces completely different results on two different sites.
Expectations based on the mechanism look like this:
- If the unused code is inside a render-blocking stylesheet in the head, the gain shows up in the first paint and the LCP chain.
- If the unused code is inside a large script parsed and compiled while the page loads, the gain shows up in interaction latency and, indirectly, in the render delay component of LCP.
- If the unused code is in a file that is already lazy-loaded after the page loads, deleting it cleans up the codebase but may not move your metrics in any noticeable way.
That is why prioritization should be based on blocking time, not file size. Removing a single third-party script that blocks the main thread for a quarter of a second does far more than trimming a large stylesheet that returning visitors already get from cache.
Set the right expectations about timing as well. Lab tools show the change the moment you deploy it. Reports based on real user data, however, use a rolling window of recent weeks, so they keep showing the old state until enough new visits have accumulated. Checking the report the day after a cleanup and drawing conclusions is a mistake most teams make, and it hurts morale for no reason.
Frequently Asked Questions
Is cleaning up unused code still necessary with Brotli or gzip compression?
Yes, because compression only reduces the network cost. Once the file reaches the browser, it is unpacked and processed at full size: every CSS rule goes into the style model, and every line of JavaScript is parsed and compiled. Compression shortens download time; it does not shorten the time spent on the main thread. On slow devices, the real bottleneck is usually processing, not downloading.
Coverage says most of a file is unused. Can I delete all of it?
No. That ratio reflects only the execution that happened while recording, and only on that page. Code triggered by interactions, code that runs on errors and code used on other templates all show up as unused in this measurement. To make a decision, repeat the measurement on every important template while performing real interactions, then combine the reports. The set to delete is the set that is unused in every measurement.
Is dequeuing a plugin's assets enough instead of removing the plugin?
Partly. Dequeuing only stops the CSS and JavaScript files from loading on that template; the plugin's server-side code, database queries and hooks keep running. If the plugin is genuinely used on some pages, conditional loading is the right answer. If the feature is no longer used at all, removing the plugin entirely is both faster and safer than settling for a half-measure.
Does loading a library from a CDN solve the unused code problem?
No, it only changes where the file is downloaded from. The library's unused parts are still downloaded, parsed and compiled. On top of that, a script loaded from an external domain becomes third-party code in measurement terms and counts toward the main thread blocking budget. A CDN is a distribution decision, not a code weight decision.



