I Replaced 12 npm Packages With Browser APIs Nobody Uses. Here's What Happened.
How I audited our production app's dependency tree, replaced a dozen packages with native browser APIs, and cut our bundle by 58%. A practical guide to the platform features hiding in plain sight.
The Moment I Actually Looked Inside node_modules
It started with a Slack message from our platform team. "Your frontend bundle is 1.4 MB gzipped. Can we talk?" I had been ignoring the bundle analyser warnings for months. We all do. The app worked, users weren't complaining loudly enough, and there were always higher-priority tickets.
But that Slack message came with a graph. Our bundle had grown 340% in eighteen months. Not because we had built 340% more features - we had maybe added 40% more UI surface area. The rest was dependencies. Libraries we had pulled in for convenience, each one dragging along its own dependency tree, each tree overlapping with others in ways nobody had audited.
So I did something I had never properly done before: I ran npx depcheck and actually read the output. Then I opened node_modules in my editor and started browsing. What I found was genuinely unsettling.
We had moment.js shipping 320KB of locale data for a date picker that only served Indian users. We had lodash - the full library, not individual imports - because one file used cloneDeep. We had a uuid package for generating IDs that the browser could do natively in a single function call. We had a modal library wrapping functionality that HTML has supported natively since 2022.
The pattern was always the same: a developer (often me, if I am being honest) needed some functionality, Googled it, found a Stack Overflow answer from 2019 recommending a package, ran npm install, and moved on. Nobody ever went back to check whether the platform had caught up.
"We weren't shipping a web application. We were shipping an archaeology museum of every JavaScript problem the browser had already solved."
The 12 Packages I Removed (And What Replaced Them)
I spent a weekend cataloguing every dependency in our production app that had a native browser equivalent. The rule was simple: if a Web API could do the same job with reasonable browser support (95%+ on caniuse.com for our user base), the package was a candidate for removal.
Here is the final list. Twelve packages, ranging from tiny utilities to massive libraries, all replaced by code the browser already ships for free:
The other six: classnames (replaced with template literals), lodash.debounce (replaced with a six-line utility), node-fetch (native fetch in Node 18+), scroll-into-view-if-needed (replaced with Element.scrollIntoView()), is-number (replaced with Number.isFinite()), and deep-equal (replaced with targeted comparison logic).
Total JavaScript removed from the production bundle: ~412KB unminified, ~147KB gzipped. That is not a rounding error. That is real bandwidth your users are paying for on every single page load.
Killing moment.js Was the Easy Win Nobody Takes
Let me be direct: if you are still shipping moment.js in 2026, you are making a choice to send your users 320KB of JavaScript for something the browser does natively. Moment's own maintainers have said the project is in maintenance mode. It is not controversial to replace it. And yet I still see it in production bundles constantly.
The Intl.DateTimeFormat API has been supported in every major browser since 2017. It handles locale-aware date formatting out of the box. Here is what the migration looked like for our most common use case - displaying relative timestamps like "3 hours ago":
import moment from 'moment'; // 320KB imported for this one call const label = moment(timestamp).fromNow(); // "3 hours ago"
const rtf = new Intl.RelativeTimeFormat('en-IN', { numeric: 'auto' }); function timeAgo(date) { const diff = date - Date.now(); const hours = Math.round(diff / 3600000); const days = Math.round(diff / 86400000); if (Math.abs(hours) < 24) return rtf.format(hours, 'hour'); if (Math.abs(days) < 30) return rtf.format(days, 'day'); return new Intl.DateTimeFormat('en-IN').format(date); }
Is this more code than a single moment().fromNow() call? Yes. But it is fifteen lines that ship zero additional bytes to your users. And unlike moment, it respects the user's actual locale preferences without you bundling every locale file on the planet.
Three Packages That Should Never Have Existed
Some of the replacements were so trivially simple that it made me question how we ended up with npm packages for them in the first place. These three took less than five minutes each to migrate.
uuid - crypto.randomUUID()
The uuid package has over 130 million weekly downloads on npm. It generates RFC 4122 compliant UUIDs. Since 2021, every modern browser ships crypto.randomUUID() which does exactly the same thing, is cryptographically secure, and requires zero imports.
// Before: 7KB package + import overhead import { v4 as uuidv4 } from 'uuid'; const id = uuidv4(); // After: 0KB, built into every browser const id = crypto.randomUUID();
lodash.cloneDeep - structuredClone()
Deep cloning objects used to be genuinely hard in JavaScript. The JSON.parse(JSON.stringify(obj)) hack lost functions, dates, and undefined values. Lodash solved this properly. But since 2022, structuredClone() is available in all major browsers and handles dates, Maps, Sets, ArrayBuffers, and circular references correctly.
// Before: 18KB of lodash internals import cloneDeep from 'lodash.clonedeep'; const copy = cloneDeep(state); // After: native, handles more types than lodash const copy = structuredClone(state);
copy-to-clipboard - navigator.clipboard
The clipboard package used to be necessary because the old document.execCommand('copy') API was unreliable and required a visible text selection. The modern Clipboard API is promise-based, works without DOM hacks, and has been universally supported since 2020.
// Before import copy from 'copy-to-clipboard'; copy('some text'); // After await navigator.clipboard.writeText('some text');
Each of these packages solves a real problem that genuinely needed solving at the time it was created. That is the important nuance. The package authors weren't wrong to build them. But the ecosystem has a collective memory problem - we keep installing solutions to problems the platform solved years ago.
Replacing react-modal With the <dialog> Element
This was the migration I was most nervous about. Our app had 14 different modals, all built with react-modal. The library handled focus trapping, scroll locking, backdrop click-to-close, escape key handling, and portal rendering. Replacing it with a native element felt risky.
It turned out to be the most satisfying removal of the entire project.
The HTML <dialog> element, when opened with .showModal(), gives you all of this for free. Focus is trapped inside the dialog automatically. The backdrop is a CSS pseudo-element you can style. The Escape key closes it by default. It is rendered in the top layer, so z-index issues disappear entirely. Screen readers announce it correctly without ARIA attributes.
function Modal({ isOpen, onClose, children }) { const ref = useRef(null); useEffect(() => { const dialog = ref.current; if (isOpen && !dialog.open) dialog.showModal(); if (!isOpen && dialog.open) dialog.close(); }, [isOpen]); return ( <dialog ref={ref} onClose={onClose}> {children} </dialog> ); }
That is the entire component. No focus trap library. No portal. No scroll lock utility. The browser handles all of it. Our modal component went from 180 lines (with all the react-modal configuration and accessibility workarounds) to 15 lines.
This is the part that surprised me most. Our react-modal implementation had three accessibility bugs that our automated a11y tests never caught - incorrect aria-labelledby references, focus not returning to the trigger element on close, and the modal being announced as "dialog" but not "modal dialog" by some screen readers. The native <dialog> element handled all three correctly by default. We removed code and improved accessibility simultaneously.
URLSearchParams: The API That Replaced an Entire Category of Packages
The query-string package is one of those dependencies that feels invisible. It parses URL query parameters into objects and serialises them back. Simple, useful, and something you stop thinking about once it is installed.
But URLSearchParams has been in browsers since 2016. It does everything query-string does, plus it handles URL encoding correctly by default - something that trips up manual implementations constantly.
// Before import qs from 'query-string'; const params = qs.parse(window.location.search); const url = `/search?${qs.stringify({ q: 'test', page: 2 })}`; // After const params = new URLSearchParams(window.location.search); const q = params.get('q'); // individual access const all = Object.fromEntries(params); // full object const newParams = new URLSearchParams({ q: 'test', page: '2' }); const url = `/search?${newParams}`;
The one caveat: URLSearchParams does not handle nested objects like query-string can with options like { arrayFormat: 'bracket' }. If your URL params have deeply nested structures, you might still need a library. But frankly, if your query strings are that complex, you have a different problem to solve first.
The same pattern applies to the URL constructor itself. I found three places in our codebase where we were using string concatenation and manual encoding to build URLs. The URL and URLSearchParams APIs handle encoding, path joining, and parameter management correctly without any of those error-prone string operations.
The Small Packages That Add Up Silently
The remaining replacements were smaller individually but collectively significant. Each one followed the same pattern: a package that solved a real problem in 2018, now replaced by a platform feature that shipped quietly while nobody was paying attention.
classnames - Template Literals
The classnames package (or its lighter cousin clsx) conditionally joins CSS class names. It is used in nearly every React project I have ever seen. But modern JavaScript makes it unnecessary for most cases:
// Before import cx from 'classnames'; const cls = cx('btn', { 'btn-active': isActive, 'btn-lg': size === 'large' }); // After: filter(Boolean).join() pattern const cls = [ 'btn', isActive && 'btn-active', size === 'large' && 'btn-lg' ].filter(Boolean).join(' ');
lodash.debounce - Six Lines
Debounce is a genuinely useful utility, but the implementation is trivial enough that importing a package for it is hard to justify:
function debounce(fn, ms) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; }
scroll-into-view-if-needed - Element.scrollIntoView()
The scrollIntoView method now accepts a behavior: 'smooth' option and a block parameter to control alignment. The package we had installed added "scroll only if the element is not already visible" logic - which is useful, but implementable with a single IntersectionObserver check before calling scrollIntoView.
The Numbers After the Migration
I measured everything before and after. Not just bundle size - build times, test execution, dependency audit surface, and npm install duration. Here are the results:
Reduction (gzipped)
Removed (gzipped)
Eliminated
npm Install
The bundle size reduction was the headline number, but the dependency count drop mattered more to me from a maintenance perspective. Each transitive dependency is a potential supply chain vulnerability, a potential breaking change in a minor version, and a thing that npm audit might flag at 2am on a Friday. We went from 412 total packages in our dependency tree to 378. Thirty-four fewer things that could go wrong.
Build times improved by roughly 12%. Not dramatic, but consistent. The bundler had fewer modules to resolve, fewer files to parse, and less code to tree-shake (or fail to tree-shake, in moment's case).
The most meaningful change was invisible to metrics: cognitive load. When a new developer joins the team and opens package.json, every dependency they see is a question they have to answer. "What does this do? Do I need to learn its API? Is it still maintained?" Twelve fewer questions. Twelve fewer context switches. That compounds across every engineer who touches the codebase.
"The best dependency is the one you don't have. Not because dependencies are bad - but because every dependency is a decision you're asking every future developer to re-evaluate."
When You Absolutely Should NOT Do This
I want to be careful here because this article could easily be read as "npm packages are bad, use the platform for everything." That is not the point. Some packages exist for excellent reasons that native APIs cannot address. Here is when you should keep the dependency:
- When the native API has browser support gaps that matter to your users. If your analytics show 8% of users on older Samsung Internet or KaiOS browsers,
structuredClone()might not be safe without a polyfill. Check your actual user data, not caniuse global averages. - When the package handles edge cases the native API doesn't.
date-fnshas timezone-aware arithmetic thatIntl.DateTimeFormatdoes not cover. If you need to add 3 business days to a date respecting IST holidays, you need a library. The native API formats dates - it does not do date math. - When the abstraction genuinely reduces bugs. A form validation library like Zod is not replaceable by native APIs. It provides type inference, composable schemas, and error messages that would take thousands of lines to replicate. The value is in the abstraction, not the implementation.
- When the package is maintained by domain experts. Cryptography libraries, date-time libraries with calendar system support, internationalisation libraries handling pluralisation rules - these encode domain expertise that is genuinely hard to replicate correctly. Rolling your own is how you get security vulnerabilities.
Before removing a dependency, ask three questions: (1) Does a native API exist that covers my specific use case? Not a similar API - the exact functionality I use. (2) Is the browser support acceptable for my actual user base? (3) Would a junior developer on my team be able to maintain the native replacement without the package's documentation and community?
If the answer to all three is yes, remove it. If any answer is no, keep it and move on. Dogma in either direction is the enemy of good engineering.
Why We Keep Installing What We Don't Need
The technical migration was straightforward. The harder question is why this situation exists in the first place. Why do experienced engineers - people who know better - keep pulling in packages for things the browser already does?
I think there are three forces at work.
First, Stack Overflow and AI assistants have long memories. When you search "how to deep clone an object in JavaScript," the top answers still recommend lodash. ChatGPT and other coding assistants were trained on years of code that imports these packages. The platform evolved; the training data did not. Every developer who follows that advice perpetuates the cycle.
Second, the npm ecosystem optimises for installation, not removal. Adding a dependency takes 10 seconds. Removing one requires auditing every import, checking for transitive usage, running the full test suite, and convincing your team that the risk is worth it. The friction is asymmetric by design - it is always easier to add than to remove.
Third, we stopped reading MDN. This is the uncomfortable one. The web platform ships new APIs constantly. structuredClone landed in 2022. The Popover API shipped in 2024. CSS.highlights is available now. Most frontend developers I talk to are unaware of at least half the APIs that shipped in the last three years. We read framework changelogs religiously but treat the platform itself as static.
"The irony of modern frontend development: we obsessively track every React RFC but couldn't name three Web APIs that shipped this year."
This is not a moral failing. It is a predictable outcome of an ecosystem that moves fast and rewards framework expertise over platform knowledge. But it has a real cost - measured in bundle sizes, dependency counts, and the growing gap between what the browser can do and what we ask it to do.
What I Would Tell Every Frontend Team
If this article inspires one action, make it this: schedule a quarterly dependency audit. Not just npm audit for security vulnerabilities - a proper review asking "does this package still justify its presence in our bundle?"
The platform is catching up faster than most of us realise. Every year, a handful of packages that were genuinely necessary become optional. The ones that shipped solutions to browser deficiencies are being made redundant by the browser itself. That is not a criticism of those packages - it is the ecosystem working exactly as it should.
Here is a simple process that worked for our team:
- Run
npx depcheckto find completely unused dependencies. Remove them immediately. - Run your bundle analyser. Sort by size. For the top 10 largest dependencies, check if a native API covers your use case.
- For each candidate removal, write a small proof-of-concept. Run it against your existing tests. If the tests pass, ship it.
- Document the native API replacements in your team's engineering wiki. The next developer who reaches for
npm installshould find your team's recommendation first.
The goal is not zero dependencies. That is impractical and counterproductive. The goal is intentional dependencies - where every package in your package.json is there because you chose it, not because you inherited it from a Stack Overflow answer written before the browser solved the problem.
Intl.DateTimeFormat Intl.RelativeTimeFormat crypto.randomUUID() structuredClone() URLSearchParams <dialog> navigator.clipboard Element.scrollIntoView() IntersectionObserver
Every API listed above has 95%+ global browser support as of May 2026. Check caniuse.com for your specific user base before migrating.
Related essays
- Performance · Browser APIs
Long Tasks Are Not Why Your Page Feels Slow. Chrome Shipped An API That Finally Tells You What Is.
The Long Tasks API tells you that something blocked the main thread for 340ms. It will not tell you which script, which handler, or which line. Long Animation Frames does - and it measures the unit users actually perceive, which is the frame, not the task.
- Observability · Frontend
Frontend Observability is Not a Backend Problem
Your servers are green. Your APIs are healthy. Your dashboards look perfectly fine. And somewhere out there, a real user is staring at a blank white screen. Here is why that gap exists, and what you can actually do about it.
- Vite · Frontend Infrastructure
We Blamed Vite For A 90-Second Local Load. The Real Bottleneck Was The 11,516 Modules We Asked It For.
A local Vite page took 90 seconds to load because eager route imports pulled 11,516 modules into one page load. Making routes lazy cut it to 54 modules and 10.54 seconds. The mechanism, how to count your own modules, and where the fix stops working.