node.js

Node 18 shipped fetch and a test runner. I kept one.

Node 18 shipped fetch and a test runner. I kept one.

Node 18 arrived in April with two additions that each delete a dependency a lot of projects carry: a global fetch, and a test runner in core. Both are marked experimental. Node 18 becomes the LTS line in October, which is when most of the projects I touch will actually meet it.

Three months in, I have adopted one of them and left the other alone, and the reasoning is less about the features than about what a dependency is for.

fetch, which I took

fetch is now a global, along with FormData, Headers, Request and Response. No import, no package.

const res = await fetch('https://api.example.com/things', {
  headers: { authorization: `Bearer ${token}` },
})

if (!res.ok) throw new Error(`things: ${res.status}`)
const body = await res.json()

That is the same code I would write in a browser, which is the point. A codebase that shares utilities between a front end and a Node service has spent years either shipping a polyfill or writing two versions of every HTTP helper.

The thing to know, and it catches people: fetch does not reject on a 404 or a 500. It resolves. res.ok is the check, and a wrapper that forgets it turns every server error into a JSON parse failure two layers away. Anyone arriving from axios, which throws on non-2xx by default, will write this bug once.

It can be turned off with --no-experimental-fetch, which tells you how the Node team is thinking about the stability label.

The test runner, which I did not

node:test gives you a test runner with no install, producing TAP output:

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

test('splits keywords on whitespace', () => {
  assert.deepEqual(splitKeywords('  a  b '), ['a', 'b'])
})

It is a perfectly reasonable runner, and for a small library with no dependencies — the kind of package where adding a test framework doubles the install size — I would use it today.

I am not moving anything else. The reason is that a test framework is not really its test() function. What I actually use Jest for is the parts around the assertions: watch mode that reruns only what changed, module mocking, coverage wired up without thinking about it, snapshot testing, and the fact that every person joining the project already knows how it behaves.

A runner in core does not replace that. It replaces the smallest and least interesting part of it. Reaching for it on an existing suite means giving up tooling I use daily to remove a dev dependency that costs me nothing at runtime — which is a trade in the wrong direction.

The distinction I came away with

These two additions look like the same kind of thing and they are not.

fetch is a standard arriving in a runtime. The API was specified elsewhere, implemented everywhere, and the thing that was missing in Node was the implementation. Adopting it moves my code towards a spec that outlives any library, and the migration is deleting an import.

node:test is a tool arriving in a runtime. There is no specification for what a test runner should be, the ecosystem's options differ on purpose, and the value of the one I use is mostly in the parts core does not have.

So my rule for built-ins: take the ones that replace a polyfill, be slower about the ones that replace a choice. A polyfill exists because the runtime was behind, and when the runtime catches up the polyfill has no argument left. A tool exists because somebody made decisions, and core shipping its own set of decisions does not retire the others.

On "experimental"

Worth saying, because it decides both answers.

For fetch, experimental means the API surface might still move at the edges, and the API surface is a web standard that a great many things depend on. The risk of it changing under me in a way that matters is low, and the escape hatch is a flag.

For the test runner it means something heavier, because a test suite is a large investment in one tool's semantics. Rewriting a suite twice is a bad afternoon, and doing it because a label changed would be self-inflicted.

I will revisit both when 18 becomes LTS in October, which is the point at which "experimental in Node" starts meaning something different anyway.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev