DEV Community

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

Posted on β€’ Originally published at linkedin.com

3 1

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)

πŸ‘‹ Kindness is contagious

Explore this insightful write-up embraced by the inclusive DEV Community. Tech enthusiasts of all skill levels can contribute insights and expand our shared knowledge.

Spreading a simple "thank you" uplifts creatorsβ€”let them know your thoughts in the discussion below!

At DEV, collaborative learning fuels growth and forges stronger connections. If this piece resonated with you, a brief note of thanks goes a long way.

Okay