Trimming JavaScript: Bundles and Third Parties
JavaScript is the most expensive byte on the web. An image of the same size only needs to be decoded, whereas a script must be downloaded, parsed, compiled and executed, and all of that happens on the main thread the visitor needs for tapping and scrolling. Trimming what you ship is therefore one of the more reliable ways to improve responsiveness. This guide covers how to see what is in your bundle, the standard ways to shrink it, and how to keep third-party scripts from undoing the work.
Why size matters beyond download time
DebugBear’s write-up on bundle size lists the knock-on effects of large files: slower transfers on poor connections, longer parse and execution times even on fast ones, and worse cache behaviour, since a big bundle changes more often and gets invalidated more often. On mobile, the processing cost is usually the larger part. Long tasks of 50 ms or more block both input handling and rendering, which is why heavy startup scripts show up in Interaction to Next Paint.
Step 1: look before you cut
- Check new dependencies before installing them. Tools such as BundlePhobia and the Import Cost editor extension reveal how much a package will add, including its own dependencies.
- Visualise the bundle. A treemap tool like Webpack Bundle Analyzer shows your own code as well as packages, and often surfaces surprises. One classic example is a date library that ships every locale.
- Measure unused code. The Coverage panel in Chrome DevTools highlights what did not execute during a session, and works with source maps so you can see which source files are the offenders.
Step 2: split what is not needed immediately
Code splitting means the first page load carries only what that page uses. Dynamic import() is the building block: the module is fetched when execution reaches that line. web.dev’s example is a library only needed when a form is submitted, so it should not travel with the initial bundle.
const form = document.querySelector('#contact-form');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const { validateAndSend } = await import('./contact-submit.js');
await validateAndSend(new FormData(form));
});
Splitting by route is simpler to reason about than splitting individual libraries: each page becomes its own chunk, and many framework tools do this for you. Vendor code is commonly placed in a separate, long-cached chunk since it changes less often than application code. If a critical chunk is split off, preload it so the benefit is not cancelled by a late fetch.
Step 3: import less
Tree shaking drops exports nobody uses, but only when imports are written in a way the bundler can analyse. Pulling in a whole utility library with a single catch-all import can drag in everything, whereas named imports from a module-aware build let the unused parts fall away.
// Heavier: may include the whole library
import _ from 'lodash';
const names = _.map(users, 'name');
// Lighter: plain JS where possible
const names = users.map((u) => u.name);
Two smaller wins are worth checking. Wrap development-only code in environment checks so the minifier can delete it, and review your browser targets, because transpiling for very old browsers adds helper code. DebugBear estimates that narrowing the supported browser list can trim a bundle by 5 to 15 percent.
Step 4: replace scripts with the platform
MDN’s performance article, written with DebugBear, maps several common script patterns to native features:
- Carousels can often be built with CSS scroll snapping.
- In-viewport detection belongs to Intersection Observer.
- Deferred images and iframes can use the native
loadingattribute. - Popups can use the Popover API.
- Complex layouts are a job for CSS Grid.
- Page transitions can use the View Transitions API.
Support for some of the newer pieces, such as anchor positioning and scroll-driven animations, is still uneven, so degrade gracefully and test in browsers you do not use every day.
Step 5: tame third-party scripts
Tags for analytics, chat, reviews and A/B testing are often the largest contributors, and you do not control their code. web.dev’s guidance on loading them boils down to a few habits:
- Load them with
asyncordeferso they do not block the parser.asyncruns as soon as the file arrives, in no guaranteed order;deferwaits until parsing finishes and preserves order. - Use
preconnectonly for origins that are truly critical, because browsers close idle connections after about ten seconds. Usedns-prefetchas a lighter hint for the rest. - Lazy-load below-the-fold widgets such as embeds and review blocks, either with the native attribute or with Intersection Observer.
- Consider self-hosting a script you rely on, which gives you control of caching headers, at the price of keeping it updated yourself.
<link rel="preconnect" href="https://widgets.example.com">
<link rel="dns-prefetch" href="https://widgets.example.com">
<script src="https://widgets.example.com/loader.js" defer></script>
Note that web.dev’s third-party article dates from 2019, so treat its specific case-study numbers as historical and its principles as still sound.
Keep it from creeping back
Track bundle size in continuous integration so a pull request that adds a large dependency is visible before it merges. Periodically audit tag managers and installed apps for scripts nobody remembers adding. If you want an outside review of what your storefront or site loads, see our services, or browse our themes for builds that start lean.