Engineering Diaries · Frontend
§
FrontendDependenciesBrowser APIs

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.

01 · The Trigger

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."

02 · The Hit List

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:

Package Replacement Map
moment (320KB)
Intl.DateTimeFormat (0KB)
uuid (7KB)
crypto.randomUUID() (0KB)
lodash.cloneDeep (18KB)
structuredClone() (0KB)
query-string (5KB)
URLSearchParams (0KB)
react-modal (14KB)
<dialog> element (0KB)
copy-to-clipboard (3KB)
navigator.clipboard (0KB)

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.

03 · Dates

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":

Before: moment.js
import moment from 'moment';

// 320KB imported for this one call
const label = moment(timestamp).fromNow();
// "3 hours ago"
After: Intl.RelativeTimeFormat (0KB)
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.

The Locale Trap moment.js bundles 130+ locale files by default. Even with tree-shaking (which moment does not support well due to its CommonJS structure), you are typically shipping locale data you will never use. Intl.DateTimeFormat uses the browser's built-in locale data, which is already present on the user's device. You ship nothing.
04 · The One-Liners

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 vs After
// 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.

Deep Clone Migration
// 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.

Clipboard Migration
// 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.

05 · The Big One

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.

Native <dialog> Modal Component
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.

The Accessibility Win

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.

06 · Query Strings & URLs

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.

Query String Parsing
// 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.

07 · The Utilities

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:

Conditional Classes
// 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:

Debounce in Six Lines
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 Compound Effect Each of these packages individually adds only 2-8KB to your bundle. But we had six of them. Together with their transitive dependencies, they contributed 43KB gzipped. That is the insidious thing about small packages - they feel harmless in isolation, so nobody ever questions them. They accumulate like technical debt you can measure in kilobytes.
08 · Results

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:

58%
Bundle Size
Reduction (gzipped)
147KB
JavaScript
Removed (gzipped)
34
Transitive Deps
Eliminated
1.8s
Faster Cold
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."

09 · Caveats

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:

  1. 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.
  2. When the package handles edge cases the native API doesn't. date-fns has timezone-aware arithmetic that Intl.DateTimeFormat does 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.
  3. 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.
  4. 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.
The Decision Framework

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.

10 · The Culture Problem

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.

11 · Takeaways

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:

  1. Run npx depcheck to find completely unused dependencies. Remove them immediately.
  2. Run your bundle analyser. Sort by size. For the top 10 largest dependencies, check if a native API covers your use case.
  3. For each candidate removal, write a small proof-of-concept. Run it against your existing tests. If the tests pass, ship it.
  4. Document the native API replacements in your team's engineering wiki. The next developer who reaches for npm install should 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.

· · ·
Native APIs Used in This Article

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.

Bundle size measurements were taken using webpack-bundle-analyzer on a production build with default optimisation settings. Gzipped sizes measured with gzip -6. The "58% reduction" figure represents the JavaScript bundle specifically, not total page weight including HTML, CSS, images, and fonts. Your results will vary based on which packages you use and how much of each package your code actually imports.