What's new in Node.js 18

What's new in Node.js 18

Node.js 18 was first released back in April this year and it's becoming the Active LTS (Long Term Support) version on 25th October 2022.

At NearForm we have settled on a policy which recommends using the Active LTS for new development, so we're getting ready to migrate our active internal projects and start new ones on Node 18.

You can learn more about the Node.js release schedule here

Node 18 comes with lots of internal improvements and a few user facing new features, which we're going to cover in this article.

Global browser APIs

One of the great things about JavaScript is its ubiquity: you can use the same language both in client and server applications.

As language and APIs evolve, there has also been an ongoing effort to standardize the ways similar operations are done in code running in a browser environment and in a Node.js application. Typical examples of that are sending HTTP requests and streaming data.

Node 18 improves this by introducing support for global browser APIs, meaning that nearly-identical code can now be used for certain activities regardless of whether it's running in the server or the client, and that these APIs are accessible globally, without importing any modules.

Experimental support for global fetch

Node 18 introduces support for the global fetch API, the familiar feature we've come to love in the browser, which avoids using the low-level XMLHttpRequest object or external libraries such as axios.

The built-in Node.js HTTP client isn't very user-friendly and a common option was to rely on external libraries such as node-fetch.

Node 18 introduces experimental support for a global fetch function, which is built on top of undici and aims at being compliant with the Fetch standard.

const response = await fetch('https://nodejs.org/api/documentation.json')

if (response.ok) {
  console.log(await response.json())
} else {
  console.error(await response.text())
}

Experimental support for global Web Streams

Streams existed in Node.js long before they were introduced in browsers. Unfortunately the browser's Stream API is considerably different, so Node.js tried to fill the gaps by providing a browser-compatible Web Streams API. Web Streams were already available in Node.js, but version 18 makes them accessible in the global namespace.

Here's a fairly convoluted way of turning a string to upper case using Web Streams.

const read = new ReadableStream({
  start (controller) {
    controller.enqueue('hello world')
  }
})

const transform = new TransformStream({
  transform (chunk, controller) {
    controller.enqueue(chunk.toUpperCase())
  }
})

const write = new WritableStream({
  write (chunk) {
    console.log(chunk)
  }
})

read.pipeThrough(transform).pipeTo(write)

// outputs: HELLO WORLD

Experimental test runner module

Similar to the fragmentation of APIs for sending HTTP requests, testing tooling is also a rich part of the Node.js ecosystem, which comes with advantages and disadvantages.

Many testing frameworks are available and many have seen a rise and fall in adoption over the years. These days our general approach at NearForm is to test purely Node.js code using tap and purely frontend code using Jest.

We're never too prescriptive and we trust our teams to choose what works best for them. As an example, it's very common to see a monorepo containing both frontend and backend code using a single testing framework, because consistency is also an important quality in a codebase.

Node.js 18 introduces a novelty, a new node:test module inspired by tap.

Module prefixes

A quick digression on module prefixes is in order.

The node:test module is only available with the node: prefix, and it's the first ever Node.js module to exhibit this behavior.

import test from 'node:test';

Module prefixes are a way to instruct Node.js that we want to load a core module, not a userland module. This means that any existing core module can be loaded with the prefix and would load exactly the same non-prefixed module:

import assert from 'assert'

import fs from 'fs'
import prefixedFs from 'node:fs'

assert.strictEqual(fs, prefixedFs)

Prefixes were introduced to remove ambiguity and risk of loading unintended, possibly malicious, modules.

Using the testing module

The API of the testing module is largely similar to that of tap, although many advanced features are still missing, for example module mocks and fixtures.

import test from 'node:test'
import assert from 'assert'

test('sum', async t => {
  await t.beforeEach(async t => {
    t.context = { 
      sum: (a, b) => a + b
    }
  })

  await t.test('1 + 2', t => {
    assert.strictEqual(t.context.sum(1, 2), 3)
  })
})

What else is there?

Node.js 18 comes with a long list of other improvements, although most of them are not immediately relevant for end users. Check them out in the official announcement.