Software Engineering18 يونيو 202610 min read

The JavaScript Bundle Diet: Shipping Less Code

Most of the JavaScript you ship never runs on the first page view. Here is the engineering playbook for finding it, cutting it, and making sure it never comes back.

بقلم Innovation T Team


Your app is not slow because of your code. It is slow because of everyone else's code that you bundled with it. Most production JavaScript payloads carry entire libraries for features that render below the fold, behind a login, or never at all.

This is the playbook we use at Innovation T when a client says "the site feels heavy." No magic. Just measurement, mechanism, and enforcement.

Why Bundle Size Still Hurts in 2026

Bytes are cheap. CPU time is not. Gzip and Brotli hide the transfer cost of JavaScript, but nothing hides the execution cost: every kilobyte you ship must be decompressed, parsed, compiled, and executed on the main thread of whatever device your user is holding. A mid-range Android phone can spend several times longer processing a script than a developer's laptop does, and that work blocks input handling.

This shows up directly in your field metrics. Long main-thread tasks during hydration degrade INP. Late-discovered chunks push out LCP. If you have been staring at flaky Core Web Vitals scores, bundle weight is usually suspect number one. We covered how to read those metrics properly in our Core Web Vitals field guide.

One more reason this matters now: modern frameworks execute your bundle twice in practice, once as server-rendered HTML and once as hydration. Every unnecessary dependency taxes both.

Step Zero: Measure What You Actually Ship

Never optimize from intuition. Bundle composition is almost always surprising.

  • source-map-explorer or webpack-bundle-analyzer: treemap of what is inside each chunk, attributed to source files and node_modules packages.
  • rollup-plugin-visualizer for Vite and Rollup builds.
  • Import Cost (editor extension) to see the price of an import as you type it.
  • bundlephobia.com style checks before adding any dependency: minified size, gzip size, and whether it is tree-shakeable.
npx source-map-explorer dist/assets/*.js --html report.html

In our experience the treemap almost always reveals one of four patterns:

  • A single dependency dominating the chunk (a charting library, a rich text editor, a date library with all locales).
  • The same package bundled twice at different versions.
  • A "utility" library imported for one function.
  • Polyfills for browsers you no longer support.

Each pattern has a specific fix. Do not touch code until you know which pattern you have.

Tree Shaking: What It Actually Does

Tree shaking is dead code elimination applied across module boundaries. It only works because ES modules have static structure: import and export statements are declarative, so the bundler can build a module graph, mark which exports are actually referenced, and let the minifier delete the rest.

That sentence contains every failure mode. If the structure is not static, or the bundler cannot prove code is side-effect free, shaking stops.

The classic breakers:

  • CommonJS dependencies. require() is dynamic. Bundlers treat CJS modules as opaque blobs and keep everything. Prefer packages that ship real ESM builds, and check the exports field in their package.json.
  • Module-level side effects. If importing a file could mutate global state, register a polyfill, or inject CSS, the bundler must keep it even if you use nothing from it.
  • Transpiler output. Down-leveled classes and decorators can generate helper wrappers the minifier cannot prove pure. Keeping your build target modern (more on that below) avoids most of this.
  • Re-export hubs. Barrel files. They deserve their own section.

The sideEffects Flag

The sideEffects field in package.json is your contract with the bundler. It says: "importing these modules does nothing unless you use their exports, so drop them freely."

{
  "name": "@innovationt/ui",
  "sideEffects": ["*.css"]
}

Set it in your own packages, especially in a monorepo with internal component libraries. Marking everything except CSS as side-effect free is the single highest-leverage line of config in most design systems we audit. Get it wrong in the other direction, though, and the bundler will happily delete your polyfill imports and global styles. Test after flipping it.

Barrel Files Are Bundle Poison

A barrel is an index.ts that re-exports everything in a directory so consumers can write one tidy import:

import { Button } from "@/components";

The tidy import is the problem. Resolving it forces the bundler to load the entire barrel graph, and if any module in that graph has side effects (or is CJS, or confuses the analyzer), the whole directory rides along. Icon libraries are the notorious case: one icon import pulling in thousands.

Fixes, in order of preference:

  • Import from the concrete module path: @/components/Button.
  • Use your framework's import optimizer (Next.js has optimizePackageImports for exactly this; check the docs for your version, the config surface moves between releases).
  • If you own the barrel, keep it, but ensure every module behind it is genuinely side-effect free and ESM.

The Dependency Diet

Dependencies are where bundles go to bloat. Three moves:

Replace heavy with light. Full-fat date libraries with bundled locales lose to date-fns or dayjs with per-function imports. A utility library imported wholesale loses to per-method imports or twenty lines of your own code. An HTTP client wrapper loses to fetch, which has been everywhere for years.

Replace light with platform. The platform ate a lot of npm. Before reaching for a package, check for: Intl.NumberFormat and Intl.DateTimeFormat (formatting), structuredClone (deep copy), URLSearchParams (query strings), crypto.randomUUID (IDs), CSS scroll-driven animations (a whole class of scroll libraries). Zero bytes beats any bundle.

Deduplicate. Two versions of the same package is pure waste, and with libraries that rely on singletons (state managers, context providers) it also causes genuinely weird bugs.

npm ls react-dom
pnpm why date-fns

If you see duplicates, tighten your version ranges or add an override, then verify in the treemap that the duplicate is gone. Trust the artifact, not the lockfile.

Code Splitting That Actually Pays

Tree shaking removes code nobody uses. Code splitting defers code somebody uses, later. The distinction matters because splitting has a cost: every chunk is a request, a cache entry, and a potential waterfall.

Priority order:

  • Route-level splits. Highest value, lowest effort. Every meta-framework does this by default; your job is to not defeat it by importing heavy shared modules into a root layout.
  • Heavy interactive islands. Charting, code editors, maps, PDF viewers, video players. These are frequently the largest single items in a bundle and frequently render below the fold or behind a click.
const ChartPanel = lazy(() => import("./ChartPanel"));

<Suspense fallback={<PanelSkeleton />}>
  {showAnalytics && <ChartPanel data={data} />}
</Suspense>
  • Conditional logic. Admin panels, onboarding flows, feature-flagged experiences. If 5 percent of users see it, 100 percent should not download it.

And the failure modes:

  • Over-splitting. Dozens of tiny chunks create request waterfalls and defeat compression (small files compress worse). Split at meaningful boundaries, not per component.
  • Splitting the critical path. If a chunk is required for first paint, splitting it out just adds a round trip. Keep the critical path in the entry.
  • No loading state. A lazy chunk on a slow connection with no skeleton is a regression, not an optimization.
  • No preloading. Fetch deferred chunks on intent: hover, focus, or viewport proximity. The user clicks, the code is already there.

Ship Modern JavaScript

A quiet source of bloat is transpilation for browsers you stopped supporting years ago. Down-leveling async/await, classes, and optional chaining inflates output with helpers and state machines, and it makes the code slower to parse and harder to shake.

Audit your browserslist. If it still says something like > 0.25%, last 2 versions, ie 11, you are paying an ancient tax. A modern baseline looks like:

# .browserslistrc
defaults and fully supports es6-module

Then check what your toolchain actually targets: Vite's build.target, esbuild's target, or your Babel preset config. Also audit polyfills. Blanket core-js injection based on a stale browser list can add a large chunk of code that every supported browser already implements natively.

Put a Budget on It, Then Make CI the Enforcer

Bundles regrow. One convenient import, one "temporary" dependency, one upgraded package that quietly doubled in size. Without enforcement, every diet ends the same way.

The fix is a size budget that fails the build:

{
  "size-limit": [
    { "path": "dist/assets/index-*.js", "limit": "180 kB" },
    { "path": "dist/assets/vendor-*.js", "limit": "120 kB" }
  ]
}

Tools like size-limit or bundlesize run in seconds and post the delta on every pull request. The number itself matters less than the trend: a PR that adds 40 kB should be a conversation, not a surprise discovered in production three months later. This belongs in the same class of merge gates as tests and linting; we wrote about building that discipline in CI/CD pipelines teams trust.

Set budgets per entry point, not just globally. A global budget hides a bloated route behind a lean one.

The Bundle Diet, Step by Step

Run this sequence on any project. Each step is measurable, so you always know whether it worked.

  1. Build with source maps and generate a treemap. Screenshot it. This is your before picture.
  2. Record baseline numbers: total JS transferred, largest chunk, and lab INP/LCP on a throttled mid-range device profile.
  3. Kill duplicates. Run npm ls on your suspects, dedupe, rebuild, confirm in the treemap.
  4. Attack the biggest single package. Replace it, split it, or import it granularly. One package at a time, measuring after each.
  5. Fix barrel imports and verify sideEffects in every internal package.
  6. Split heavy below-the-fold and behind-interaction components with lazy loading plus preload-on-intent.
  7. Modernize the compile target and strip stale polyfills.
  8. Set per-entry size budgets in CI at roughly your new baseline plus a small margin, so growth needs a justification.

Steps 1 through 4 usually deliver most of the win. In our experience, a first pass on a mature app commonly cuts initial JavaScript by a third or more, though the honest answer is always "it depends on what the treemap shows."

Knowing When to Stop

Bundle optimization has diminishing returns, and past a point it costs more than it saves.

  • If your entry bundle is already lean and your INP is green in field data, stop. Ship features.
  • If you are contorting the codebase (manual chunk graphs, exotic build plugins, vendored forks) to save single-digit kilobytes, stop. Complexity is also a cost, and it compounds.
  • If every audit finds the same framework overhead at the bottom of the treemap, the problem is architectural. Sometimes the right answer is a lighter rendering strategy or a different stack for the marketing surface, which is a decision we walk through in choosing a tech stack for SaaS in 2026.

The goal was never zero JavaScript. The goal is that every kilobyte you ship earns its execution time.

How Innovation T can help

Innovation T builds and rescues production web applications. Our engineers run this exact playbook on client codebases: bundle audits with before and after numbers, tree shaking and splitting work, modern build pipelines, and CI budgets so the results stick. See our engineering services for the full picture.

If your app feels heavy and your metrics agree, talk to us. Send the URL, and we will come to the first call with your treemap already in hand.

#bundle size#performance#tree shaking#frontend

جاهز للبناء مع Innovation T؟

سواء كان الأمر يتعلق بالأمن أو النمو أو الهندسة، يمكن لفريقنا مساعدتك على تنفيذه بإتقان.