Most "best Node.js packages" lists just rename Express, Lodash, and Axios in a different order. Fine tools, all three — but they're not the ones that actually saved me time this year. The ones that did are smaller, quieter, and mostly never trend on anything. They just sit in package.json doing their one job well, and I only notice them when they're missing from a new project.
Here are ten of them, what they replace, and where I'd actually reach for each.
1. execa — for when child_process isn't worth the pain
Node's built-in child_process.exec technically works, but you're back to string-escaping shell commands and manually handling stdout/stderr buffering the moment you do anything beyond "run one command and forget about it."
import { execa } from 'execa';
const { stdout } = await execa('git', ['rev-parse', '--short', 'HEAD']);
console.log(stdout);
Arguments are passed as an array, not concatenated into a shell string, so you stop worrying about escaping. It also throws a real error with the command's actual stderr attached when something fails, instead of leaving you to go dig through a callback.
2. zx — for scripts you'd normally reach for bash for
Same author's ecosystem, different problem: zx is for the "this really should just be a bash script, except I want real variables and error handling" situation.
#!/usr/bin/env zx
const branch = await $`git branch --show-current`;
await $`npm run build`;
await $`rsync -av ./dist/ user@server:/var/www/${branch.stdout.trim()}/`;
It's genuinely fun to write deploy scripts in this. The caveat: don't reach for it inside application code — it's a scripting tool, not a library you import into a server. Keep it in your scripts/ folder.
3. p-limit — for when Promise.all is too eager
Promise.all fires everything at once. That's fine for five requests. It's how you accidentally DDoS your own API, or your own database connection pool, the moment "five" becomes "five thousand."
import pLimit from 'p-limit';
const limit = pLimit(5); // max 5 concurrent
const results = await Promise.all(
userIds.map(id => limit(() => fetchUserData(id)))
);
This is the fix I reach for every single time I'm processing a batch of anything — API calls, file uploads, database writes. It's about forty lines of source code and it's saved me from rate-limit bans more times than I can count.
4. nanoid — for IDs that don't need to be a UUID
uuid works, but a v4 UUID is 36 characters and most of the time you don't actually need RFC-compliant UUIDs — you need a short, unique, URL-safe string.
import { nanoid } from 'nanoid';
const id = nanoid(); // 'V1StGXR8_Z5jdHi6B-myT'
const shortId = nanoid(8); // 'IRFa-VaY'
Smaller output, faster generation, and it's URL-safe by default so you can drop it straight into a route without encoding it. I use this for anything that doesn't need to interoperate with a system that specifically expects UUID format.
5. pino — for logging that doesn't slow down production
console.log is fine in development and a genuine performance problem in a high-throughput production service — synchronous, unstructured, and unfiltered by log level.
import pino from 'pino';
const logger = pino();
logger.info({ userId: user.id }, 'user logged in');
logger.error({ err }, 'payment failed');
It outputs structured JSON by default, which sounds like a downside until your logs need to go into something like Datadog or Elasticsearch and you realize structured logs were the whole point. Pipe it through pino-pretty locally if raw JSON in your terminal makes your eyes glaze over — I do, every project.
6. why-is-node-running — for the process that won't exit
Every Node developer has had this moment: your script is done, but the process just... sits there. Something's holding an open handle — a socket, a timer, a database connection — and you have no idea which.
import why from 'why-is-node-running';
setTimeout(() => why(), 5000);
It prints exactly what's keeping the event loop alive, with a stack trace pointing at where each handle was created. I've used this to find a forgotten setInterval and an unclosed database pool in two separate "why won't this exit" debugging sessions that would otherwise have eaten an afternoon each.
7. tsx — for running TypeScript without a build step
ts-node works, but it's slow to start and its ESM support has been a source of pain for years. tsx is a much faster drop-in for the common case of "I just want to run this TypeScript file right now."
npx tsx script.ts
npx tsx watch server.ts
The watch flag alone replaced a nodemon + ts-node combo in most of my newer projects. It's not trying to be a full build tool — for that you still want tsc or a bundler — but for local scripts and dev servers it's the fastest path from "TypeScript file" to "running code" I've used.
8. defu — for merging config objects without losing your mind
Every project ends up needing to merge a default config with user-supplied overrides, and { ...defaults, ...userConfig } breaks the moment either object has nested properties — a shallow spread happily throws away half your defaults.
import { defu } from 'defu';
const config = defu(userConfig, {
port: 3000,
server: { host: 'localhost', timeout: 5000 },
debug: false,
});
defu does a proper recursive merge, only filling in values that are actually missing, at any depth. Small utility, but it quietly prevents a specific class of "why did my nested config option get wiped out" bug.
9. picocolors — for terminal colors without the dependency weight
chalk is the household name here, and it's a fine library — but if all you need is basic ANSI colors in CLI output, picocolors does the same job at a fraction of the size, with no dependencies of its own.
import pc from 'picocolors';
console.log(pc.green('✓ Build succeeded'));
console.log(pc.red(pc.bold('✗ Build failed')));
If you're building a CLI tool that other people will install, every dependency you pull in is a dependency they inherit. This is the kind of swap that doesn't change what your code does, only how much it costs the next person to install it.
10. ora — for CLI spinners that don't leave you guessing
If your script does anything that takes more than half a second, a silent terminal makes people wonder if it's frozen. ora gives you a spinner with almost no ceremony:
import ora from 'ora';
const spinner = ora('Deploying to production...').start();
await deploy();
spinner.succeed('Deployed successfully');
It's a small thing, but it's the difference between a CLI tool that feels finished and one that feels like a script someone ran once and never polished. Worth the two minutes it takes to wire in.
The pattern across all ten
None of these are exciting on their own. That's kind of the point — the tools that actually save time in practice are usually the ones solving one specific, annoying problem precisely, not the ones with the biggest feature list. Before reaching for a heavier framework or writing something from scratch, it's worth checking whether one of these already exists for exactly the problem in front of you. More often than I expect, one does.
We're ArtClick, a web development agency based in Kyoto. We build company websites, WordPress sites, and custom systems — with a focus on sites that are fast, well-designed, and easy to maintain long-term. Learn more at artclickdev.
Top comments (0)