{"id":"snel6kl52j6cq01","title":"JavaScript Bundle Bloat: What I Cut First on Client Audits","slug":"javascript-bundle-bloat-what-to-cut","summary":"Bundle analyzers only help if you know what safe means. I triage vendor chunks, duplicate polyfills, and eager imports before touching hero images.","imageUrl":"https://briancrabtree.me/images/journal-javascript-bundle-bloat-what-to-cut.webp","category":"JavaScript","date":"2026-01-12T18:00:00.000Z","featured":false,"likes":38,"author":"Brian Crabtree","content":"<h2>The analyzer is a map, not a verdict</h2>\n\n<p>webpack-bundle-analyzer and Rollup visualizers show weight, not value. I sort by gzip size, then ask whether the code runs before first interaction. A 200 KB chart library on a contact page is a delete. The same library on a dashboard behind auth might be fine if code-split.</p>\n\n<p>I screenshot the treemap and label each rectangle: critical path, post-LCP, admin-only, dead. Stakeholders understand colored boxes faster than abstract kilobyte lectures.</p>\n\n<p>Source maps in production help debug but double artifact size in S3; I enable hidden-source-map for error trackers only. The user never downloads them unless DevTools is open.</p>\n\n<pre><code>// vite.config — honest sideEffects for tree-shaking\nexport default {\n  build: {\n    rollupOptions: {\n      output: { manualChunks: { firebase: ['firebase/app'] } },\n    },\n  },\n};</code></pre>\n\n<h2>Duplicate polyfills and legacy targets</h2>\n\n<p>Shipping ES5 to evergreen mobile browsers is still common because babel config never got updated. I align browserslist with real analytics, drop unnecessary core-js imports, and verify that one version of tslib is deduped. Duplicate moment locales alone have wasted megabytes on blogs.</p>\n\n<p>If you must support IE11, isolate it to a separate entry or sunset it with a contract date. Do not make every user pay for Symbol polyfills they already have natively.</p>\n\n<p>License compliance scans sometimes reveal duplicate MIT libraries doing the same string helpers. Consolidating saves bytes and legal review time.</p>\n\n<h2>Barrel files and accidental imports</h2>\n\n<p>import { Icon } from ./icons when icons/index.ts re-exports lucide-react pulls the whole set. I ban careless barrels on hot paths or switch to direct imports and vite/webpack sideEffects false in package.json where honest.</p>\n\n<p>Tree shaking fails when modules mutate exports at top level. I grep for fs and path in client bundles; those are build leaks, not features.</p>\n\n<p>I compare gzip and brotli sizes; brotli wins on text bundles at CDN. Local dev still gzip is fine; production config matters for mobile last-mile.</p>\n\n<h2>Eager routes that should be lazy</h2>\n\n<p>React Router lazy() helps only if the parent does not statically import the child. I check the module graph, not just the route config comment. Firebase, maps, and rich text editors belong behind interaction or viewport gates, not in the home chunk.</p>\n\n<p>Dynamic import() per section on marketing pages often beats a SPA. If navigation is three pages, you might not need a client router at all.</p>\n\n<p>Code splitting by route without splitting data fetching still feels slow. Pair lazy routes with loader prefetch on intent for the two routes users actually open.</p>\n\n<h2>Third-party npm versus CDN tags</h2>\n\n<p>Sometimes the npm package duplicates what marketing already loaded from a tag manager. I pick one source. Self-hosting analytics wrappers can be smaller than the vendor snippet if you control batching, but only if you accept maintenance.</p>\n\n<p>Bundle size and third-party script weight add in the main thread. I report both numbers in audits so product owners see the full JavaScript bill.</p>\n\n<p>Marketing sometimes embeds A/B snippets that re-import React from a CDN while the app ships React 19. Two Reacts is a special hell. I block duplicate globals in CSP where possible.</p>\n\n<h2>Measuring after cuts</h2>\n\n<p>I rerun Lighthouse and compare TBT and INP, not just transferred bytes. A 30 KB cut that removes a long task wins; a 100 KB cut that shifts work to idle may not move lab scores. Real users care about interaction delay.</p>\n\n<p>CI bundle budgets catch regressions. I set maxInitialSize on the main entry and fail builds when marketing adds another carousel dependency without approval.</p>\n\n<p>Bundle budget PR comments with treemap diff image keep regressions visible. Numbers without pictures do not stick with stakeholders.</p>\n\n<h2>What ships on briancrabtree.me</h2>\n\n<p>This site keeps the home JavaScript small: route chunks load after paint, Firebase waits until needed, icons are inline SVG where possible. The discipline is boring and repeatable.</p>\n\n<p>Send your analyzer screenshot. I will mark delete, defer, and lazy on it in one pass. That is usually the fastest win before image optimization even starts.</p>\n\n<p>After cuts, I verify error rates in Sentry did not spike. Aggressive tree shaking can remove side-effect imports that registered service workers or polyfills you still needed. For a related angle I keep coming back to, see <a href=\"/journal/third-party-scripts-performance-cost/\">Third-Party Scripts: The Performance Cost Marketing Underestimates</a>.</p>","tags":["javascript","bundles","performance"],"views":104}