Node.js has quietly stopped being the thing you bolt a pile of tools onto. In 2026, a Node 22 or 24 LTS install gives you ESM as the default module system, a native test runner that rivals Jest for most workloads, a stable permission model, native fetch, watch mode, and .env file loading — all without a single extra dependency. This post walks through what shipped, what it replaces, and where the older tools still earn their slot in the package.json.
The platform, in one table
Most of the features below arrived incrementally between Node 20 and Node 24. The table consolidates what is stable, what is still experimental, and what replaces a familiar dependency.
| Feature | Status in Node 24 LTS | Replaces | Notes |
|---|---|---|---|
| ESM as default | Stable | CommonJS require in new code | require(esm) is also stable — mixed codebases work |
| node:test runner | Stable | Jest/Mocha for most cases | Subtests auto-await, worker IDs exposed, SIGINT-aware |
| Permission model | Stable (--permission) | Manual sandboxing/containers for dev | File, network, child process, worker guards |
| Native fetch | Stable | node-fetch, axios for simple clients | undici under the hood, streams supported |
| Watch mode | Stable (--watch) | nodemon for most cases | Works with --env-file and node:test |
| .env file loading | Stable (--env-file) | dotenv for simple setups | Multi-file support, --env-file-if-exists flag |
| TypeScript type stripping | Enabled by default | ts-node, tsx for scripts | No enums/namespaces without --experimental-transform-types |
| require(esm) | Stable | Dynamic import() in CJS | Synchronous interop with ESM packages |
ESM is the default now — and that is fine
ESM has been the default in new Node projects for long enough that the interop story has stopped being painful. The two changes that made the difference: require(esm) landed as stable, so CommonJS packages can pull in ESM dependencies without switching the whole file to await import(); and the tooling ecosystem finally caught up. Most popular packages ship dual builds or pure ESM, and the ones that do not tend to be legacy modules that belong in a rewrite anyway.
The practical migration path for an existing CommonJS codebase is no longer all-or-nothing. Switch new files to .mjs or add "type": "module" to a package.json for that folder. Use .cjs for the files that still need synchronous require. Over a few sprints, the surface area of CommonJS shrinks to whatever genuinely needs it. The pattern that does not work: trying to ESM-ify a codebase in one PR. Do it incrementally, module by module, and ship in between.
If a dependency ships ESM-only and breaks your CJS entrypoint, require(esm) is usually the right fix. Upgrade to Node 22 LTS or later and the require call will work without transpilation. Swapping the whole app to ESM to accommodate one package is overkill.
The native test runner covers more than you think
The node:test runner has matured past the point where teams should treat it as a toy. It ships with describe/it, before/after hooks, subtests, mocking, snapshot support, coverage via --experimental-test-coverage, and a TAP and dot reporter out of the box. For a greenfield Node service, there is a solid argument for starting on node:test and only reaching for Jest if a specific feature genuinely blocks you.
// A native-test-runner file, no dependencies needed
import { describe, it, beforeEach, mock } from "node:test";
import assert from "node:assert/strict";
import { createUser } from "./users.ts";
describe("createUser", () => {
let db: { insert: ReturnType<typeof mock.fn> };
beforeEach(() => {
db = { insert: mock.fn(async () => ({ id: "u_1" })) };
});
it("writes an email-lowercased row", async () => {
await createUser(db, { email: "Ada@Example.com", name: "Ada" });
assert.equal(db.insert.mock.callCount(), 1);
assert.deepEqual(db.insert.mock.calls[0].arguments[0], {
email: "ada@example.com",
name: "Ada",
});
});
it("rejects invalid emails", async () => {
await assert.rejects(
() => createUser(db, { email: "not-an-email", name: "Ada" }),
/invalid email/i,
);
});
});
// Run with: node --test --experimental-test-coverage src/**/*.test.tsWhat node:test does not give you, in practice: a mature plugin ecosystem (jest-extended, testing-library integrations are Jest-first), parallel sharding for large monorepos, and some of Jest's DX niceties like first-class snapshot diffing in watch mode. For a standalone service with a few hundred tests, those are not blockers. For a 10,000-test monorepo with a bespoke CI pipeline, staying on Jest is still defensible.
When to still use Jest or Mocha
- You already have thousands of Jest tests and no budget to migrate. The cost to switch rarely pays back.
- You rely on a Jest plugin that has no node:test equivalent — visual regression, custom environments, or specific React Testing Library integrations.
- You need fine-grained test sharding across a self-hosted CI fleet. Jest's sharding is more battle-tested.
- Your team genuinely prefers the Jest DSL. Consistency across a team is worth more than shaving a dev dependency.
The permission model is stable — and worth turning on
The permission model shipped as stable in Node 23.5 and is the default behavior behind --permission in Node 24 LTS. It restricts what a running Node process can do: read/write specific paths, open network connections, spawn child processes, or create workers. Any of those actions hit a permission check, and the process either allows them because the flag was passed or fails loudly.
# Start Node with restricted access — fails on anything outside the allowlist
node \
--permission \
--allow-fs-read=./config,./data \
--allow-fs-write=./data/uploads \
--allow-net=api.example.com:443 \
--allow-child-process \
server.js
# During development, audit without enforcing — log every violation
node --permission-audit server.js
# Inside the process, check a specific permission
# const { permission } = await import("node:process");
# permission.has("fs.read", "/etc/passwd"); // falseThe model matters most for services that run untrusted code — plugin sandboxes, user-submitted scripts, build workers in multi-tenant CI. It also pays off in normal SaaS backends that want defense in depth: a compromised dependency still cannot read /etc/passwd or open a reverse shell if the permission set does not allow it. The overhead is trivial; the habit of writing an explicit allowlist is the harder part.
The permission model is not a replacement for containers or OS-level isolation. It raises the bar meaningfully against supply-chain attacks, but a determined attacker with code execution inside your Node process will still find ways to misuse what you did allow. Treat it as one layer, not the layer.
Fetch, watch mode, and .env — the papercuts that disappeared
Three smaller additions removed dependencies that most Node projects carried for a decade. Native fetch, backed by undici, is now stable and fast. Watch mode (--watch) replaces nodemon for the vast majority of dev loops. The --env-file flag loads a .env file at process startup without requiring dotenv. None of these are individually exciting; together they cut the typical package.json by half a dozen entries on a fresh project.
- Native fetch is a drop-in replacement for node-fetch in most cases. The main gap is axios-style interceptor APIs — if you need those, axios still has a role. For plain HTTP calls, fetch is fine.
- Watch mode works together with --env-file and --test, so you can run node --watch --env-file=.env --test src/**/*.test.ts and get a full TDD loop with zero dependencies.
- For multi-environment .env loading, chain --env-file flags. Later files override earlier ones, so --env-file=.env --env-file=.env.local reproduces the dotenv-flow precedence most teams already use.
TypeScript — the bit that is still awkward
Node can run TypeScript files directly since 22.18, and type stripping is enabled by default in Node 24. The catch: the runtime strips types, it does not compile them. Enums, namespaces, parameter properties, and other features that require actual JavaScript emission need --experimental-transform-types, which is still flagged. Anything the TypeScript compiler would have to transform — not just erase — lives in the experimental lane.
Type stripping is fast enough for scripts, CLIs, and many service workloads. It is not a replacement for a real build if you depend on enums, decorators with emit metadata, or downleveling for older runtimes. Run tsc --noEmit in CI for type checking either way — the runtime never checks types, only strips them.
The pragmatic 2026 setup for a new Node service: write .ts files, run them directly with node under development, use tsc --noEmit in CI for type checking, and skip ts-node and tsx entirely. For production, most teams still ship compiled JavaScript via esbuild or tsc — the boot time is lower and the emitted output is stable across Node minor versions. The direct-run path is best for dev, tests, and one-off scripts.
A 2026 starter that earns its zero-dependency label
The package.json for a new Node backend service can genuinely start at zero runtime dependencies in 2026. HTTP via node:http or a small framework, tests via node:test, env loading via --env-file, watch mode for dev, fetch for outbound calls, and TypeScript via type stripping. A week of writing that code makes it obvious how much of the old Node.js muscle memory was working around gaps that no longer exist.
{
"name": "service",
"type": "module",
"scripts": {
"dev": "node --watch --env-file=.env --env-file-if-exists=.env.local src/server.ts",
"test": "node --test --experimental-test-coverage 'src/**/*.test.ts'",
"test:watch": "node --watch --test 'src/**/*.test.ts'",
"typecheck": "tsc --noEmit",
"start:secure": "node --permission --allow-fs-read=./config --allow-net=api.example.com:443 src/server.ts"
},
"devDependencies": {
"typescript": "^5.7.0"
}
}Key takeaways
- Node 22 and 24 LTS finally deliver the platform story Node has been promising for years — ESM default, built-in tests, stable permissions, native fetch, TypeScript without a build step.
- node:test covers most SaaS backend test workloads. Stay on Jest if you have thousands of tests or depend on its plugin ecosystem; otherwise, greenfield on node:test.
- Turn on --permission for services that run untrusted code and for defense-in-depth on normal backends. Start with --permission-audit to see what your app actually needs.
- require(esm) means mixed ESM/CJS codebases are no longer painful. Migrate incrementally, not in one PR.
- Type stripping is great for dev and tests. Keep tsc --noEmit in CI and a real build for production binaries.
- A Node service in 2026 can genuinely ship with zero runtime dependencies. It is worth trying once just to internalize what the platform now covers.