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"
}
}
π‘ 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
orpeerDependencies
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
dependencies
anddevDependencies
to 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 topeerDependencies
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 ownpeerDependencies
." - 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()
.
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 ) |
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.
π 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 ofimport { 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)