DEV Community

Cover image for Bundle Optimization: Key concepts & Practical Guide
Capucine Bois for Onepoint

Posted on • Originally published at linkedin.com

Bundle Optimization: Key concepts & Practical Guide

Recently on a project, we received alerts about abnormal network traffic. While such issues often come from excessive XHR requests, in this case the problem was a heavy JS bundle downloaded on each first load. Our app was too heavy, too expensive, and not eco-friendly. The main culprit? A monorepo dependency, a whopping 5MB! My team and I had created this monorepo, packed with sub-libraries used in our frontend. ๐Ÿคฏ

As we dug into optimization, we uncovered a world of bundle size reduction, dependencies, build time vs. runtimeโ€”and after some deep dives, we cut our bundle size by 51%! ๐ŸŽฏ

Sounds like something worth sharing, right? Many developers struggle with peerDeps, devDeps, dependencies, externalDeps, etc. It's time to clear things up. Letโ€™s gooo! ๐Ÿš€ (Fun fact: Even after optimizing, our frontend was still too big, so we cached it client-side! )


I. Key concepts

๐Ÿš€ย Package Versioning and Dependency Classification

Correct versioning helps you avoid conflicts and keeps your projects stable. Hereโ€™s a quick overview of version range notations (time to make a screenshot ๐Ÿ“ธ, youโ€™ll need this more than once):


Operator Example Allowed Updates Behavior
Exact version "1.3.0" Only 1.3.0 No updates allowed
Tilde ~ "~1.3.0" 1.3.x (patch updates only) Updates within 1.3.x but not 1.4.0
Caret ^ "^1.3.0" 1.x.x (minor & patch) Updates within 1.x.x but not 2.0.0
Caret ^ <1.0.0 "^0.1.3" 0.1.x (patch updates only) Updates within 0.1.x but not 0.2.0
Caret ^ =0.0.x "^0.0.3" Only 0.0.3 No updates allowed
Greater than ">=1.2.0" 1.2.0 and above Always picks the highest available version
Wildcard * "*" Any version Use with caution. Allows all updates
Range ">=1.2.0 <2.0.0" All 1.x versions from 1.2.0 up to (but not including) 2.0.0 Gives you precise control over acceptable versions

See : https://nodesource.com/blog/semver-tilde-and-caret

๐Ÿ’ก Tip: Choose your version ranges carefully. Too broad a range might introduce unexpected breaking changes.


Dependency Type Definition & Purpose Example
Dependencies Installs the module inside the package, making it available at runtime and included in the final bundle. Note: Not recommended for React in a monorepo as it can cause multiple instances. Lodash, React
Peer Dependencies Prevents the module from being installed inside the package (not included in the final bundle). Important: Ensures the consuming app provides the module as a dependency, avoiding duplication. Reminder: PeerDeps do not inherit between libraries; each library must declare its own peerDependencies. React (in a context of a custom library published via npm package for example)
Dev Dependencies Needed only for development (e.g., for standalone package testing or TypeScript compilation). They do not affect runtime execution and are not part of the final bundle. ESLint, Rollup
External Dependencies (optional) Useful mostly for non-externalizing bundlers that do not automatically exclude peerDependencies from the final bundle (like Rollup or Parcel). peerDepsExternal() prevents bundling of specified modules to reduce bundle size. Complementary to peerDependencies. React (in a context of an custom library + when using a non-externalizing bundler)

Example package.json:

{
  "name": "my-project",
  "version": "1.0.0",
  "dependencies": {
    "react": "^17.0.0",
    "lodash": "^4.17.21"
  },
  "devDependencies": {
    "eslint": "^7.32.0",
    "rollup": "^2.56.2"
  },
  "peerDependencies": {
    "react": "^17.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ’ก Note: Peer dependencies are mostly used in custom libraries (a library meant to be consumed by either a frontend or backend application). This ensures that the consumer provides the necessary dependencies itself, preventing unnecessary duplication and keeping the project lightweight. This is particularly useful in monorepos multiple custom libraries, as it helps avoid version conflicts and unnecessary bloat.


๐Ÿš€ย Build Time vs. Runtime

Understanding the difference between build time and runtime is key to optimizing your code and know how to organize your dependencies.

๐Ÿ“ฆ Build Time

  • What Happens:

Build time refers to everything that happens to your code before it's ready to run. This includes transforming, validating, optimizing, and finally bundling it into deployable assets.


Step Description Scope
Transpilation Converts TypeScript (TS) or modern JS into browser-compatible JS Optional
JSX Transformation Converts JSX into function calls (via Babel or TypeScript) React-specific
Linting & Type Checking Validates code quality and types Optional but recommended
CSS Preprocessing Compiles SCSS, LESS, etc. to CSS Optional
Bundling Aggregates your source files and their dependencies Always
Minification & Tree-Shaking Optimizes bundle size and removes dead code Production only

Tools involved at this stage include TypeScript, Babel, ESLint, Rollup, and Terser.

These tools live in your devDependencies because they are only needed to prepare the codeโ€”not to run it.


  • Build modes : triggered by NODE_ENV project variable

    • Development Build: Unminified, includes source maps, and keeps readable filenames for easier debugging.
    • Production Build: Minified (via Terser), uses hashed filenames for caching, and applies tree-shaking to remove unused code, making the source harder to recognize.

  • Building vs. Bundling

    In short:

    • Build: The full transformation pipeline from source code to production-ready output including transpilation, type-checking, bundling, and optimizations like tree-shaking and minification.
    • Bundling: A core step within the build that aggregates transformed modules into deployable assets. It may include tree-shaking.

๐Ÿ“ฆ Runtime

  • What Happens:

    Runtime is when your code is actually executed by the JavaScript interpreter (like V8 in Chrome or Node.js).


  • From Build to Run:

    The build phase transforms and optimizes your code; the runtime phase is when that final code is interpreted and executed.


  • Runtime Dependencies:

    These are the packages listed in dependencies or peerDependencies that your app still relies on at execution time. Even if they were declared during build, they remain essential for runtime behavior.


Now that we understood build time, runtime, and the different types of dependencies, we can move on to organizing our dependencies effectively. With this foundation, we are now ready to explore various techniques to optimize our bundle, ensuring better performance and efficiency ๐Ÿ˜„.


II. Optimization Techniques & best practises

In the context of my work, I had to focus on optimizing a monorepo that was being imported into my frontend, which led to significant overweight issues. To address this, Iโ€™ll organize my development approach with general thoughts applicable to a classic app project, as well as more specific insights tailored to the optimization of monorepos / custom libraries.


๐Ÿš€ย Analyse before you optimize

Before diving into techniques, remember: optimization always starts with analysis. Use tools like vite-bundle-visualizer, depcheck, or npm ls to identify whatโ€™s actually inflating your bundle. Donโ€™t optimize blindly, measure first, then act.

To help you throughout your optimization journey, I recommend using these essential commands. They allow you to see whatโ€™s left to optimize, track your progress, and identify areas for improvement.


Command Purpose Example
npm dedupe Removes duplicate dependencies Run before deploying
npm why my_dep Explains why a package is installed Debug dependency issues
npm ls my_dep Shows installed versions Check for version mismatches
npx depcheck Finds unused dependencies Clean up package.json
npx vite-bundle-visualizer Visualizes bundle size Identify heavy dependencies
npx npm-check - u Interactive dependency review Upgrade, remove, or reclassify deps easily
npx source-map-explorer dist/bundle.js Analyzes bundle content via sourcemaps Explore which modules weigh most

๐Ÿš€ย Dependency optimization: Where Should a Library Go?

Placing libraries in the right category is essential.

๐Ÿ’กGeneral rule: Do not list the same library under both dependenciesand devDependenciesto prevent conflicts.


Use this guideline:

1/ App Project (Frontend or Backend) Context

This applies to projects like React apps, Node.js backends, or any application that runs in production.


Library Type ๐Ÿ“š dependencies peerDependencies devDependencies Example ๐Ÿ’ก
Frameworks & Globals ๐ŸŒ Yes โœ… No โŒ No โŒ react, react-dom, express
Utility Libraries ๐Ÿ› ๏ธ Yes โœ… No โŒ No โŒ lodash, date-fns
Build & Dev Tools ๐Ÿ› ๏ธ No โŒ No โŒ Yes โœ… eslint, jest, webpack, vite
Dev-Only Libraries ๐Ÿ’ป No โŒ No โŒ Yes โœ… typescript, husky

2/ External / Custom Library (or Monorepo of Multiple Libraries) Context

This applies to reusable libraries, component libraries, or monorepos where dependencies should not be bundled but rather expected from the consumer (classic app project that need to import your custom library).


Custom Library

Library Type ๐Ÿ“š dependencies peerDependencies devDependencies Example ๐Ÿ’ก
Frameworks & Globals ๐ŸŒ No โŒ Yes โœ… Yes โœ… react, react-dom, @emotion/react
Utility Libraries ๐Ÿ› ๏ธ Yes โœ… No โŒย (depends on dep size) Yes โœ… No โŒ (depends on dep size) No โŒ Yes โœ…ย (if the library is neither under peerDep or dep) lodash, date-fns
Build tools & Dev only libraries ๐Ÿ› ๏ธ No โŒ No โŒ Yes โœ… eslint, jest, rollup, vite, typescript, local development configs

You list a package under devDependencies in addition to peerDependencies so that your library can work properly during local development and testing, while still leaving it up to the consumer to install that package in their app.
Ex: for React

  • devDependencies: Lets you use React to develop and test your components.
  • peerDependencies: Tells the consumer to provide React.

Monorepo

In a monorepo, internal libraries can import each other, unlike external libraries meant for consumption by an app. This internal linking requires slightly different dependency declarations:


Scenario ๐Ÿ—๏ธ Use Case ๐Ÿ’ก packB as dependency in packA packB as peerDependency in packA packB as devDependency in packA
Internal Dependency as a Dependency packA includes packB, consumer installs only packA Yes โœ… No โŒ No โŒ
Internal Dependency as a Peer Dependency packA expects packB, consumer installs both packA and packB No โŒ Yes โœ… Yes โœ…

๐Ÿ’กTip : Each package must declare its own peer dependencies; they do not automatically carry over :

  • myth: "If packB declares React in peerDependencies, then packA (which uses packB) does not need to declare React in its own peerDependencies." - reality: Peer dependencies do not inherit automatically, so packA and packB must declare React as a peerDependency.

A useful tool to help you handle your monorepo : https://jamiemason.github.io/syncpack/


๐Ÿš€ Configuration-Level Optimization: Minification, Tree Shaking & Beyond

Tuning your project configuration is one of the smartest ways to reduce bundle size without touching your business logic. Techniques like externalization, tree-shaking, and minification help strip away whatโ€™s unnecessary and keep your builds lean and efficient.


1. ๐Ÿ“ฆ Externalization โ€” *Skip bundling what the app already has*

Remember we talked about externalizing dependencies earlier :

When youโ€™re building a reusable library or package, you often rely on big libraries like react, lodash, or emotion. These are usually already installed in the consuming app โ€” so bundling them again is wasteful.

Solution: Declare them as peerDependencies as stated above , and exclude them from the final bundle using your bundler config.

  • Tools like Vite, Webpack, or ESBuild often do this automatically.
  • With Rollup, you may need to manually externalize them using a plugin like peerDepsExternal().

externalization-meme


2. ๐ŸŒฒ Tree-Shaking โ€” *Remove unused code automatically*

Tree-shaking eliminates code thatโ€™s never used โ€” like unused utility functions or unused exports from libraries.

To benefit from it, make sure:

  • Youโ€™re using ES Modules (ESM) because CommonJS (require) cannot be tree-shaken.
  • Your exports are named and flat, not wrapped in a default export.

    For example:

    โœ… export const Button = () => {}

    โŒ export default { Button, Input }

    โžก๏ธ This allows the bundler to include only whatโ€™s used (e.g., just Button, not the whole module).

๐Ÿ‘‰ Tree-shaking is supported by modern bundlers like Webpack, Rollup, and ESBuild, but itโ€™s only fully effective in production mode and with ESM. Also, some bundlers (like Webpack) require setting "sideEffects": false in package.json to eliminate code safely.


Bundler Tree-shaking Production mode required (NODE_ENV)?
Webpack โœ… Yes โœ… Yes (with--mode production)
Rollup โœ… Yes โŒ No
ESBuild โœ… Yes โœ… Yes (NODE_ENV + --minify)

tree-shaking-meme

For further reading ๐Ÿ‘‰ย Understanding Tree Shaking in JavaScript: A Comprehensive Guide


3. ๐Ÿ”ป Minification โ€” *Make the final bundle as small as possible*

Minification compresses your JavaScript by removing whitespace, comments, and shortening variable names without affecting the actual logic.

โš™๏ธ Typically handled by tools like Terser, ESBuild, or your bundlerโ€™s production mode.

Again, it is in production mode where minification becomes crucial: a large file during dev can shrink significantly once minified, speeding up load times and improving performance.

minification-meme


๐Ÿš€ Code Optimization & Refactoring: Streamline Your Code

In addition to dependency management and build configuration, code optimization is key to reducing bundle size and improving performance. Here are some practical ways to optimize your code:


1/ Refactor Large Components

Break large components into smaller, focused units for better tree-shaking. This makes it easier for bundlers to exclude unused code and improves maintainability.

2/ Remove Dead Code

Identify and remove unused code like old components or functions. This can significantly reduce your bundle size.

3/ Lazy Load Large Modules

Load only the necessary pieces of your app initially, and defer loading larger components or routes until needed.

4/ Optimize Dependencies

Minimize the impact of large libraries by importing only the specific functions you need or by using lighter alternatives.

๐Ÿ’ก Bonus Tip:

  • Import individual functions from modularized libraries (e.g., import uniqueId from 'lodash.uniqueId' instead of import { uniqueId } from 'lodash'). This reduces the bundle size by only including the necessary code, thanks to how modularized libraries are structured.
  • Use more lightweight alternatives like date-fns instead of the bulkier moment.js.

Otherwise, just follow SOLID principles ๐Ÿ™‚


III. Practical Example: Optimizing Dependencies in a Monorepo

A full practical example is available here: https://github.com/capucine-bois/dependencies-optimization.git

(if youโ€™re feeling lazy : you can just take a look at the README.md of the repo ๐Ÿ˜Œ)


IV. Conclusion

In this guide we covered essential practices for:

  • Package Versioning: Using clear version ranges to avoid conflicts.
  • Dependency Classification: Understanding the roles of dependencies, peer dependencies, and dev dependencies.
  • Monorepo Management
  • Build vs. Bundling: Knowing the difference between preparing your code and packaging it efficiently.
  • Bundle Optimization: Using techniques such as minification, tree-shaking, and externalization to reduce file size.

Good luck ! ๐Ÿ€

Top comments (0)