
When Your Tiny Package Has a Secret Dependency Hoard
You built a small, focused React component. It's 4KB. You push it to npm. Then you look at the dependency tree and realize it's pulling in half the internet. What happened? And how do you fix it before your users pay the price?
by Sunil Band
The Silent Dependency Bloat
We've all been there. You're building a new feature, you need a specific utility or a small React component, and naturally, you reach for npm. You find a package that seems perfect: a concise API, a small footprint, does exactly what you need. You install it, use it, and move on. What you often don't do is audit its actual impact on your project's bundle size or its transitive dependency count. This is where the silent killer lurks: the innocent-looking package that secretly drags in half the internet.
I recently audited a tiny React component I'd published, something I thought was just a few kilobytes of my own code. Imagine my surprise when npm ls revealed it was pulling in 116 dependencies. For a component whose core logic was maybe 200 lines of code! This isn't just about disk space on your node_modules folder; it's about build times, potential security vulnerabilities, and ultimately, the JavaScript bundle your users have to download and parse. Every require or import in your dependency chain can lead to unexpected overhead.
The package.json Lie
When we talk about package size, we often look at package.json and count direct dependencies and devDependencies. But that's just the tip of the iceberg. The real issue is transitive dependencies – the dependencies of your dependencies, and so on. A single, seemingly benign dependency can expand into a sprawling network of packages, each with its own baggage.
My component's package.json was clean, or so I thought:
{
"name": "my-scroll-stacking-component",
"version": "1.0.0",
"description": "A tiny React component for scroll-stacking effects",
"main": "dist/index.js",
"module": "dist/index.esm.js",
"files": ["dist"],
"scripts": {
"build": "rollup -c",
"dev": "rollup -c -w"
},
"keywords": ["react", "scroll", "stacking"],
"author": "Sunil Band",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
},
"devDependencies": {
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-node-resolve": "^15.2.3",
"@babel/core": "^7.23.9",
"@babel/preset-env": "^7.23.9",
"@babel/preset-react": "^7.23.3",
"rollup": "^4.9.6",
"rollup-plugin-terser": "^7.0.2"
}
}Notice anything? My devDependencies included rollup, @rollup/plugin-babel, @rollup/plugin-node-resolve, @babel/core, @babel/preset-env, @babel/preset-react, and rollup-plugin-terser. These are all build tools, essential for me to build the package, but absolutely unnecessary for someone consuming the package. Yet, somehow, these were being dragged into the final install for users.
devDependencies Are Not Just for Devs (if you're not careful)
The core of the problem lies in how npm (and other package managers) handle devDependencies when publishing. By default, npm publish will include devDependencies in the package tarball if they are needed for building the package during the prepublishOnly or prepare scripts. However, they are not installed when someone installs your package as a dependency, unless you explicitly list them in dependencies.
So, why was my tiny component pulling in all those build tools? Because I wasn't being explicit enough. My rollup -c command was running, creating dist files, and those dist files were what users ultimately consumed. The devDependencies were correctly not being installed by users. But the problem wasn't in npm's dependency resolution for users, it was in my build output.
The real culprit, in my case, was a misunderstanding of how Rollup's external option works with @rollup/plugin-node-resolve. I wanted React and ReactDOM to be peer dependencies, meaning the consuming application should provide them, not my component. I had this in my rollup.config.js:
// rollup.config.js
import babel from '@rollup/plugin-babel';
import resolve from '@rollup/plugin-node-resolve';
import { terser } from 'rollup-plugin-terser';
const packageJson = require('./package.json');
export default {
input: 'src/index.js',
output: [
{ file: packageJson.main, format: 'cjs', sourcemap: true },
{ file: packageJson.module, format: 'esm', sourcemap: true }
],
// This tells Rollup to treat these as external, so they aren't bundled
external: ['react', 'react-dom'], // <-- This was the key for peerDeps
plugins: [
babel({
babelHelpers: 'bundled',
presets: ['@babel/preset-env', '@babel/preset-react']
}),
resolve() // <-- This was the silent killer
// terser() // Only for production builds
]
};The resolve() plugin tells Rollup how to find modules in node_modules. It's incredibly useful, but it also means that if you're not careful with your external configuration, Rollup will bundle anything it can resolve from node_modules into your output. My external array only listed react and react-dom. What about other things that might accidentally get pulled in by Babel plugins or other minor utilities I might use internally? Without proper externalization, Rollup will try to bundle them.
When I refactored my component, I had introduced a small internal utility that, while tiny, had its own (also tiny) dependency. And resolve() was bundling that. The 116 dependencies number was actually a combination of my devDependencies (which were only relevant to my build process, not the user's install) and a few transitive dependencies that were getting bundled into my dist files due to incomplete externalization.
The Fix: Explicitly Externalize and Audit Your dist
The solution involved a few steps:
- Strict
externalconfiguration in Rollup: Ensure that any dependency that should not be bundled into your component's output is explicitly listed asexternal. This includes peer dependencies, but also any utility libraries you might use but expect the consuming app to provide or that you want to avoid bundling.
// rollup.config.js (improved)
import babel from '@rollup/plugin-babel';
import resolve from '@rollup/plugin-node-resolve';
import { terser } from 'rollup-plugin-terser';
const packageJson = require('./package.json');
export default {
input: 'src/index.js',
output: [
{ file: packageJson.main, format: 'cjs', sourcemap: true },
{ file: packageJson.module, format: 'esm', sourcemap: true }
],
// Dynamically externalize all direct dependencies and peer dependencies
// This ensures that anything listed in 'dependencies' or 'peerDependencies' is NOT bundled
external: Object.keys(packageJson.dependencies || {})
.concat(Object.keys(packageJson.peerDependencies || {})),
plugins: [
babel({
babelHelpers: 'bundled',
presets: ['@babel/preset-env', '@babel/preset-react']
}),
// The resolve plugin is still useful for *internal* imports, but shouldn't bundle externals
resolve()
// terser() // Only for production builds
]
};By dynamically pulling dependencies and peerDependencies from package.json, I make sure that Rollup never bundles those. If a dependency is missing from package.json that should be external, Rollup will warn me.
- Use
npm packandtar -tzf: Before publishing, runnpm pack. This command creates a.tgzfile, exactly what npm would publish. Then, inspect its contents withtar -tzf your-package-name.tgz. This shows you exactly what files will be included in your published package. You should only see yourdistfiles,package.json,README.md, etc., not yoursrcfiles,node_modules, ordevDependencies.
If you see extraneous files, check your .npmignore file or the files array in package.json. The files array is generally preferred as it's an opt-in mechanism, ensuring only what you explicitly want is included.
My package.json files array was already set to ["dist"], which was good. This meant my devDependencies themselves weren't being shipped. The issue was that their output was being bundled into my dist files because of a lax external config.
- Audit the final bundle: After building, look at the size of your
dist/index.js(or.esm.js) file. If it's surprisingly large for a small component, open it up. Do you see code from a huge library that shouldn't be there? This is your ultimate check. Tools likesource-map-exploreror Rollup's ownvisualizerplugin can give you a graphical breakdown of what's inside your bundle.
After these changes, my dist files shrank dramatically, and the effective dependency footprint for users dropped to just react and react-dom (as peer dependencies). The npm ls output in a consuming project became much cleaner, reflecting only the peer dependencies and nothing else from my component's internal build process.
The Trade-offs of a Lean Package
Going lean has its obvious benefits: faster installs, smaller bundles, fewer potential security vectors. But it's not without its trade-offs. The primary one is increased vigilance. You need to be more deliberate about your build configuration, especially with bundlers like Rollup or Webpack. You can't just throw resolve() at it and assume it'll do the right thing for a library.
Another trade-off can be internal complexity. If you strictly externalize everything, you might end up with a build process that relies on a specific version of a utility that's not a peer dependency, but also not bundled. This can lead to issues if the consuming app has a conflicting version. This is why for very small, shared internal utilities, it's often simpler to just bundle them if they are genuinely tiny and don't introduce their own deep dependency trees.
For a general-purpose library, though, the rule of thumb is: if it's not your unique code and it's not a peer dependency, it should probably be bundled only if it's a truly tiny, stable utility without its own transitive baggage. Otherwise, externalize it and make it a dependency (if you ship a runtime dependency) or peerDependency (if the consumer must provide it).
Wrapping up
Don't assume your small package is actually small. The dependency graph can hide monsters. The next time you publish a component or utility, run npm pack and inspect the tarball. Use a bundler visualizer to confirm what's actually making it into your dist files. And for Rollup, be deliberate with your external configuration. Your users' bundle sizes (and build times) will thank you. Try auditing one of your own published packages today. You might be surprised by what you find.

When Your Frontend Needs to Go Beyond the Official API: The Power of Reverse Engineering
Official APIs are great, but sometimes they fall short. We'll explore how libraries like YouTube.js tap into internal APIs by reverse engineering, and why this technique can unlock capabilities you never knew existed.

When Your Mobile Camera Needs to Be More Than Just a Photo Button
React Native's built-in camera capabilities are fine for simple snapshots, but what if you need real-time computer vision, custom effects, or advanced control over the sensor? That's where libraries like react-native-vision-camera come in. It lets you tap into the raw power of the device camera, ope

When Your Postgres Database Needs to Be More Than Just Storage
We've all used Postgres. It's solid, reliable. But what if your database could do more than just store data? Supabase turns Postgres into a full development platform with real-time, auth, and more, all while keeping the database as the source of truth.


















