{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"packages-you-can-delete-from-your-node-js-project-0zij9","url":"https://zyvop.com/packages-you-can-delete-from-your-node-js-project-0zij9","title":"Packages you can delete from your Node.js project","subtitle":"13 packages, 389 installed, 59.7 MB. What Node’s built-ins replace, tested on Node 22, 24 and 26, and where they still bite.","tldr":"I installed 13 popular packages and got 389 packages and 59.7 MB in node_modules. Then I replaced them with what ships in Node: --watch, --env-file, fetch, node:test, type stripping and more. Every swap has a test, and the gotchas are listed, including a flag Node 26 removed.","keywords":["JavaScript","Dependencies","TypeScript","npm","node.js","Tutorial"],"entities":["Sanju Singh","JavaScript","Dependencies","TypeScript","npm","node.js","Tutorial","ZyVOP"],"keyTakeaways":["Every claim here has a test.","Run on Node 22.22.2, 24.22.1 and 26.12.1.","Install sizes measured October 24, 2026."],"headings":["The numbers","jest and mocha: use node:test","nodemon: use --watch","dotenv: use --env-file","axios: use fetch","tsx and ts-node: use type stripping","chalk: use util.styleText","minimist and yargs: use util.parseArgs","uuid: use crypto.randomUUID","rimraf, mkdirp and cpy: use node:fs","glob: use fs.glob","lodash: use structuredClone and Object.groupBy","What I'd keep","Removing a package properly","Run it yourself"],"outboundLinks":["https://www.stepsecurity.io/blog/20-popular-npm-packages-compromised-chalk-debug-strip-ansi-color-convert-wrap-ansi"],"contentText":"Every claim here has a test. Run on Node 22.22.2, 24.22.1 and 26.12.1. Install sizes measured October 24, 2026. I installed 13 popular packages into an empty project. npm reported 389 packages and 59.7 MB in node_modules. Then I wrote replacements using only what ships with Node, and tested each one. The result is a project with zero dependencies and a 50-test suite. Not every swap is worth making. Some of these packages are tiny, and deleting them saves almost nothing on disk. I'll say which ones, and I'll point out the sharp edges in the built-ins too. Baseline is Node 24, the current Active LTS. Node 26 is scheduled to enter LTS this month. When something needs a version newer than 22, the section says so. The numbers Each package was installed alone in a clean directory. \"Packages\" counts everything npm put in node_modules, including the package itself. Package Replace with Packages Size jest 30.5.2 node:test 278 36.5 MB tsx 4.23.15 native type stripping 3 12.0 MB mocha 12.0.3 node:test 25 11.2 MB rimraf 6.1.3 fs.rm 9 6.8 MB glob 13.0.6 fs.glob 7 6.5 MB axios 1.20.0 fetch 27 2.8 MB nodemon 3.1.14 --watch 28 1.4 MB lodash 4.18.1 structuredClone, Object.groupBy 1 1.4 MB mkdirp 3.0.1 fs.mkdir 1 107 KB uuid 14.0.3 crypto.randomUUID 1 66 KB chalk 6.0.1 util.styleText 1 57 KB minimist 1.2.8 util.parseArgs 1 55 KB dotenv 18.0.9 --env-file 1 47 KB Installed together, all 13 land at 389 packages and 59.7 MB. Jest is most of that. Jest also pulls in an old glob. On my install, npm printed a deprecation warning for glob@10.5.0, which arrives through test-exclude under Jest's transform package. jest and mocha: use node:test node:test has been stable since Node 20. It covers what most suites use: describe, it, hooks, mocks and fake timers. import { describe, it, mock } from 'node:test'; import assert from 'node:assert/strict'; describe('what jest users reach for', () =&gt; { it('mock.fn() records calls', () =&gt; { const add = mock.fn((a, b) =&gt; a + b); assert.equal(add(1, 2), 3); assert.equal(add.mock.callCount(), 1); }); it('fake timers', (t) =&gt; { t.mock.timers.enable({ apis: ['setTimeout'] }); const fn = t.mock.fn(); setTimeout(fn, 5000); t.mock.timers.tick(5000); assert.equal(fn.mock.callCount(), 1); }); });Run it with an explicit pattern: node --test \"test/**/*.test.mjs\"The quotes and the pattern both matter. By default Node runs every .js, .mjs and .cjs file under any directory named test, so your helpers and fixtures get executed as tests too. Coverage works, but it's still behind a flag. The docs mark it Experimental on both Node 24 and 26, and you need --experimental-test-coverage. Mock timers went stable in Node 23.1.0. I didn't port a large suite. If yours has thousands of tests built on Jest-specific matchers, moving is a project, not an afternoon. nodemon: use --watch node --watch server.mjsWatch mode has been stable since Node 22.0.0 and 20.13.0. My test starts a script, rewrites the file, and waits for a second boot message. On Node 26 the whole test, first boot and restart included, finished in about 300 ms. nodemon has a config file, a restart delay and an exec mode for running non-Node commands. If you use any of those, keep it. dotenv: use --env-file node --env-file=.env --env-file=.env.local server.mjs--env-file was added in Node 20.6.0 and is no longer experimental as of 24.10.0. The tests pin down the behavior people usually ask about: Quotes are stripped, inline # comments are ignored, and an export prefix works. Multi-line quoted values work. A real environment variable beats the same key in the file. With several files, the later one wins. A missing file is an error. Use --env-file-if-exists (Node 22.9+) to skip it. In code, process.loadEnvFile() loads a file into process.env. util.parseEnv() parses a string and leaves process.env alone. One gap: Node doesn't expand variables. A line like GREETING=hi ${APP_NAME} keeps the ${APP_NAME} text as is, and my test asserts exactly that. If you rely on expansion, keep dotenv-expand. axios: use fetch fetch has been in Node since 17.5 and stable since 21.0.0. This is the helper I'd write for the common case: JSON, a timeout, and retries with backoff on 5xx and 429. import { setTimeout as sleep } from 'node:timers/promises'; export class HttpError extends Error { constructor(status, url) { super(`${status} ${url}`); this.name = 'HttpError'; this.status = status; } } const retryable = (status) =&gt; status === 429 || status &gt;= 500; export async function getJSON(url, { timeoutMs = 5000, retries = 2, baseDelayMs = 200 } = {}) { for (let attempt = 0; ; attempt++) { try { const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); if (res.ok) return await res.json(); throw new HttpError(res.status, url); } catch (err) { const giveUp = attempt &gt;= retries || (err instanceof HttpError &amp;&amp; !retryable(err.status)); if (giveUp) throw err; await sleep(baseDelayMs * 2 ** attempt); } } }Three things bite people coming from axios, and each has a test against a local server. fetch doesn't reject on a 404 or a 500, so you have to check res.ok. Timeouts come from AbortSignal.timeout(ms), which rejects with a TimeoutError. Posting JSON means setting the content-type header and calling JSON.stringify yourself. There are no interceptors. If your codebase leans on them everywhere, keep axios until you've moved that logic into a wrapper like the one above. tsx and ts-node: use type stripping node server.tsNode strips the type annotations and runs what's left. It's on by default from 23.6.0 and from 22.18.0 on the 22 line, and it went stable in 24.12.0. The limits are real, and every one is a test: There's no type checking. My fixture assigns a string to a number and runs fine. enum and parameter properties like constructor(private x: number) fail with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. Importing a type without import type crashes at runtime with does not provide an export named 'Point'. Relative imports need the .ts extension, and Node ignores tsconfig.json, paths included. Node 22 through 25 have --experimental-transform-types, which brings enums back. Node 26.0.0 removed that flag. If you're heading to Node 26, rewrite enums as plain objects. Keep the typescript package for tsc --noEmit. The Node docs recommend TypeScript 5.8 or newer with erasableSyntaxOnly and verbatimModuleSyntax, so the compiler flags syntax Node can't run before you hit it. chalk: use util.styleText import { styleText } from 'node:util'; console.log(styleText(['bold', 'red'], 'build failed'));styleText has been stable since Node 22.13.0 and 23.5.0. By default it checks whether the stream is a terminal. My tests show plain text when output is piped, escape codes with FORCE_COLOR=1, and plain text again with NO_COLOR=1. Pass { validateStream: false } to force the codes. Be honest about the size: chalk 6.0.1 is 57 KB with no dependencies. Deleting it saves nothing on disk. The case for deleting it is your lockfile. On September 8, 2025, chalk 5.6.1 and debug 4.4.2 were among 18 packages published with malicious code, after maintainer Josh Junon fell for a phishing email from a lookalike npm domain. StepSecurity's write-up lists the affected versions. The payload went after crypto wallets and transactions in browser contexts. Every package you don't install is one less maintainer account between you and your production server. minimist and yargs: use util.parseArgs import { parseArgs } from 'node:util'; const { values, positionals } = parseArgs({ allowPositionals: true, options: { port: { type: 'string', short: 'p', default: '3000' }, verbose: { type: 'boolean', short: 'v', default: false }, tag: { type: 'string', multiple: true }, }, });parseArgs landed in Node 18.3.0 and 16.17.0. It's strict by default, so an unknown flag throws ERR_PARSE_ARGS_UNKNOWN_OPTION. Values are never coerced, which means --port 8080 gives you the string '8080'. --no-color style negation only works if you pass allowNegative: true (Node 22.4.0 and 20.16.0 or later). You get values and positionals. There's no help text and no subcommands. For a real CLI, keep commander or yargs. uuid: use crypto.randomUUID import { randomUUID } from 'node:crypto'; randomUUID(); // v4 globalThis.crypto.randomUUID(); // same thing, no importMy test generates 10,000 and gets 10,000 unique values. If you use v7 IDs, Node 24.16.0 added crypto.randomUUIDv7(). Two IDs generated 5 ms apart sort in creation order in my test. The function doesn't exist on Node 22, so that test skips there. Keep uuid if you need v1, v3 or v5, or its parsing and validation helpers. rimraf, mkdirp and cpy: use node:fs import { mkdir, cp, rm } from 'node:fs/promises'; await mkdir('a/b/c', { recursive: true }); await cp('a', 'copy', { recursive: true }); await rm('copy', { recursive: true, force: true });mkdir with recursive: true returns the first directory it created, or undefined if everything already existed. fs.cp has been stable since Node 22.3.0. Add force: true to rm if you want rm -rf behavior. Without it, a missing path throws ENOENT, and my test checks that too. rimraf 6.1.3 brings 9 packages and 6.8 MB for this. glob: use fs.glob import { glob } from 'node:fs/promises'; for await (const file of glob('src/**/*.md', { exclude: ['**/node_modules/**'] })) { console.log(file); }fs.glob is stable from Node 24.0.0, and it's experimental on 22. Glob patterns in exclude arrived in 22.14.0 and 23.7.0. Before that, exclude only took a function. Two differences from the package. It returns an async iterator, not an array. And negation patterns like !foo.js aren't supported in exclude, as the docs say. glob 13.0.6 installs 7 packages and 6.5 MB. lodash: use structuredClone and Object.groupBy const copy = structuredClone(config); const byRole = Object.groupBy(users, (u) =&gt; u.role);structuredClone is a global since Node 17. It copies Date, Map, Set and circular references correctly. It isn't a drop-in for _.cloneDeep, though. I ran both on the same inputs. structuredClone throws a DataCloneError on an object with a function property, where lodash keeps the function reference. It also drops class prototypes: a cloned Point is no longer instanceof Point, while lodash's copy still is. Object.groupBy returns an object with a null prototype, unlike _.groupBy. Spread it ({ ...grouped }) before passing it to code that calls .hasOwnProperty. Map.groupBy handles non-string keys. Swap the two or three functions you actually call. Don't pull lodash out of a codebase that leans on _.merge or _.debounce. What I'd keep node:sqlite is the tempting one. The docs list it at Stability 1.2, release candidate, since Node 24.15.0 (25.7.0 on the other line). A release candidate isn't stable, so I wouldn't replace better-sqlite3 on a production service yet. I didn't benchmark either. I'd also keep TypeScript itself, dotenv-expand if you use variable expansion, and commander or yargs for anything with subcommands. Removing a package properly npm uninstall jest takes it out of package.json. It doesn't remove copies that another package still requires. Run npm ls glob (or whatever you just deleted) after each removal. In my combined install, glob appeared in two versions: 13.0.6 and the deprecated 10.5.0 that Jest's tree carries. Run it yourself The download is delete-these-packages.zip. It has no dependencies, so there's no install step. src/ holds the two small modules from this post, http.mjs and args.mjs. test/ has one test file per section, 50 tests in total. measure/measure.sh reproduces the install numbers, and measure/results.tsv has mine. Run npm test on Node 24 and all 50 pass. Node 22.22.2 passes 49 and skips the randomUUIDv7 test. Node 26.12.1 passes 49 and skips the transform-types test. Start with the four swaps that cost nothing to try: --watch, --env-file, randomUUID and styleText.","contentHash":"sha256:67b726b84e0f3e1df70c25edd9cc7ff4363f31ccf5bf0866ce13b045fcfa7e02","authorName":"Sanju Singh","authorUrl":"https://zyvop.com/author/sanjay687","authorSameAs":[],"category":"Tutorial","tags":["JavaScript","Dependencies","TypeScript","npm","node.js"],"audience":"Software engineers and developers building applications with Tutorial","tone":"Practical and evidence-based engineering guidance","readingTimeMinutes":8,"wordCount":2027,"faqs":null,"primaryTopic":"Tutorial","publishedAt":"2026-10-24T11:29:23.831Z","updatedAt":"2026-10-24T11:29:23.831Z","canonicalUrl":"https://zyvop.com/packages-you-can-delete-from-your-node-js-project-0zij9"}