[Software Architecture, Frameworks & Libraries]

24 Sep 2026

-

7 min read time

Node.js Built-ins That Can Replace Your npm Dependencies

Audit npm dependencies against Node.js built-ins. Compare fetch, environment files, filesystem tools and testing, with examples and migration checks.

Kalle Bertell

By Kalle Bertell

Violet hands fit a green block into a compact toolkit and lift away a separate white block, illustrating a dependency audit.

A dependency audit often starts with an outdated package and ends with a question: do we still need this package at all?

For a Node.js service, the answer has changed over the years. HTTP requests, basic test execution, environment files and several filesystem operations are available in the runtime. A service originally assembled on Node 14 can carry dependencies that a newer application would never install.

Removing them can reduce maintenance work. It can also introduce subtle changes to error handling, configuration or file matching. The useful exercise is to inspect what your application actually uses and replace that behavior deliberately.

This guide uses Node 24 as its baseline. Check the exact minor version in development, CI and production before changing a dependency. The examples below use APIs available in Node 24.14; the linked Node 24 documentation also describes later additions. Features announced for Node 26 should not be assumed to exist in a Node 24 deployment.

Which packages are worth reviewing first?

Start with direct dependencies used in a small number of places. A UUID helper in two files is a better first candidate than the HTTP client behind every integration.

Replacement to review

Check before removal

node-fetch → fetch

HTTP errors, timeouts and response parsing

dotenv → --env-file

Precedence, expansion and startup commands

nodemon → --watch

Extra watched files and lifecycle hooks

uuid → randomUUID

Required UUID version and storage format

mkdirp → fs.mkdir

Paths, permissions and error handling

rimraf → fs.rm

Platform behavior and safety boundaries

glob → fs.glob

Pattern semantics, exclusions and ordering

Small CLI parsers → parseArgs

Validation, help output and subcommands

Terminal colors → styleText

Output streams and color support

Small test suites → node:test

Assertions, mocking and reporting needs

TypeScript runner → type stripping

Syntax support and separate type checking

These are review candidates, not promises of interchangeable APIs. Flavio Copes's tour of Node built-ins is a useful starting inventory. For an application migration, we would work through the list by risk and ownership rather than trying to remove every package in one pull request.

Replace a thin HTTP wrapper with fetch

If a package is only being used to make a request and parse JSON, native fetch is a sensible candidate. A wrapper around it gives you one place to express the application's error policy.

  export async function getJson(url, timeoutMs = 5000) {
  const response = await fetch(url, {
    headers: { accept: 'application/json' },
    signal: AbortSignal.timeout(timeoutMs),
  });

  if (!response.ok) {
    throw new Error(`Request failed with HTTP ${response.status}`);
  }

  return response.json();
}

Fetch does not reject merely because the server returned an HTTP error status. The explicit status check matters. The timeout signal also makes a decision that would otherwise be easy to leave implicit. See Node's global API documentation for fetch and AbortSignal.

Before replacing Axios or another richer client, inventory its configuration. Look for interceptors, retry policies, authentication refresh, proxy handling, custom agents, uploads and response transformations. Reimplementing all of that might create more code for your team to maintain than keeping the dependency.

Write compatibility tests around your own wrapper. Include a successful response, an HTTP 503, malformed JSON and a connection that exceeds the deadline. Check whether callers inspect a particular error shape. A change from an Axios error to a generic Error can break monitoring or retry decisions even when happy-path requests work.

Move development utilities into the Node command

For a straightforward local service, the startup command can load an environment file and restart when watched code changes:

  node --env-file=.env --watch server.mjs

The Node command-line reference documents both options. Environment variables already present in the process take precedence over values from the file. Native loading should not be treated as a replacement for a separate variable-expansion package. Watch mode also needs checking against any custom nodemon behavior your project uses.

Review package scripts, container commands, IDE launch settings and CI jobs together. Removing an import from the entry point is insufficient if a migration script still relies on dotenv side effects.

A common source of mistakes is moving configuration loading later in execution. A module can read an environment variable when it is first imported. Loading the file before the application starts avoids that particular ordering problem, but you still need to validate required configuration and keep secrets out of logs.

Keep the operational change small. Replace environment loading first, exercise every entry point, and then consider the process watcher. That makes a failed deployment easier to diagnose.

Use filesystem primitives for narrow jobs

Node's filesystem APIs cover recursive directory creation, recursive removal and globbing. The following disposable example writes a report, finds it and removes its temporary workspace:

  import {
  glob, mkdir, mkdtemp, rm, writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const workspace = await mkdtemp(join(tmpdir(), 'report-demo-'));

try {
  await mkdir(join(workspace, 'reports'), { recursive: true });
  await writeFile(join(workspace, 'reports', 'sample.json'), '{}');

  for await (const file of glob('reports/*.json', { cwd: workspace })) {
    console.log(file);
  }
} finally {
  await rm(workspace, { recursive: true, force: true });
}

For a build script, compare the actual sets of files produced by the old and new implementations. Include hidden files, nested folders, unusual filenames and whichever operating systems your team supports. Sort the results explicitly if downstream processing depends on order.

Deletion deserves a separate review. The example only removes a directory it created itself. A production cleanup tool should define an equally clear boundary. A path supplied through a command-line option should not flow straight into recursive removal without validation.

Do not replace an entire fs-extra dependency after finding one equivalent function. Search its other uses first. File copying, directory preparation and overwrite behavior may be spread across release scripts that are rarely exercised on a laptop.

Keep command-line scripts small

For a script with two or three flags, util.parseArgs can replace a small parsing dependency. util.styleText covers simple styled terminal messages. Their supported options are documented in Node's utilities API .

  import { parseArgs, styleText } from 'node:util';

const { values } = parseArgs({
  args: ['--dry-run', '--environment', 'staging'],
  options: {
    'dry-run': { type: 'boolean', default: false },
    environment: { type: 'string', default: 'development' },
  },
  strict: true,
});

if (!['development', 'staging', 'production'].includes(values.environment)) {
  throw new Error('Unsupported environment');
}

console.log(styleText('green', `Target: ${values.environment}`));
console.log({ dryRun: values['dry-run'] });

Here the arguments are fixed so the example is reproducible. A real command would normally read the process arguments. Parsing a string is only one part of a usable CLI: the example still performs its own validation.

Keep a dedicated library when it provides substantial value through subcommands, completion, generated help or consistent error messages. A ten-command deployment tool and a one-off report script have different maintenance needs.

Replace random UUID generation only when the format matches

For a random UUID v4, Node exposes crypto.randomUUID:

  import { randomUUID } from 'node:crypto';

const requestId = randomUUID();
console.log(requestId);

The crypto reference specifies its behavior. It does not replace requirements for other UUID versions, sortable identifiers, short public IDs or deterministic names.

Check what consumes the identifier before changing its generator. A value used only in request logs has different constraints from a primary key replicated into a warehouse. Existing rows, validators and API consumers all form part of that contract.

Removing your direct dependency may still leave the same package in the lockfile through another library. That is normal. Record whether the change reduced direct maintenance responsibility, transitive packages, or both.

Try node:test on a bounded suite

The built-in runner is useful for small backend modules and utility packages. It works with Node's assertion library:

  import test from 'node:test';
import assert from 'node:assert/strict';

function payableCents(subtotal, discount) {
  if (discount < 0 || discount > subtotal) {
    throw new RangeError('Invalid discount');
  }
  return subtotal - discount;
}

test('applies a valid discount', () => {
  assert.equal(payableCents(1200, 200), 1000);
});

test('rejects a discount larger than the subtotal', () => {
  assert.throws(() => payableCents(1200, 1300), RangeError);
});

Save this as payable.test.mjs and run node --test payable.test.mjs. The test runner documentation covers discovery, reporters and mocking.

Before converting an established Jest or Vitest suite, list the features it depends on. Browser environments, transformed imports, framework plugins, snapshot conventions and module mocking can make a migration expensive. Prove the fit on an independent package before committing the whole application.

Treat test coverage as a continuity requirement. A dependency cleanup should not quietly remove assertions because they are inconvenient to port.

Native TypeScript execution has a narrower job

Node can execute supported TypeScript by stripping erasable type syntax. It does not type-check the program, read tsconfig to reproduce your build, or become a JSX compiler. The TypeScript documentation explains the restrictions and version history.

This can simplify a small maintenance script with ordinary type annotations. It is a poor reason to remove a working application toolchain without checking path aliases, imported file extensions, generated code and syntax that needs transformation.

Keep type checking as an explicit CI step. A script starting successfully proves that Node can execute that path; it does not prove that its types are correct. For an audit, choose one representative script and run both its functional checks and the existing type checker before changing shared commands.

What should stay on the caution list?

Built-in SQLite deserves a separate decision. Node 24.21 documentation labels node:sqlite as a release candidate, and earlier Node 24 minors have a different stability history. Evaluate it against your exact runtime and database requirements using the SQLite reference .

Likewise, a built-in WebSocket client does not remove the need for a WebSocket server implementation. A new runtime feature should be matched to its actual role in your architecture.

Avoid treating dependency count as a performance score. Removing a tiny helper may improve ownership without changing application latency. Replacing a mature library with a large internal abstraction can increase maintenance effort while producing a cleaner-looking package.json.

Make the audit reviewable

For each proposed removal, record the runtime baseline, the imports being replaced, the behavior that must remain and the checks used to verify it. Include the dependency's other consumers and the rollback change.

Land one category at a time. Exercise the deployment path, inspect logs and check scripts outside the main application. Once the replacement is in use, remove the unused dependency and regenerate the lockfile with the project's package manager.

If the audit exposes broader problems with runtime upgrades, integration behavior or release confidence, those deserve their own work items. Makers' Den's code audits and backend development work can help turn that inventory into a manageable sequence of changes.

Kalle Bertell

By Kalle Bertell

More from our Blog

Keep reading