A photo of Maximilian Schwarzmüller

Maximilian Schwarzmüller

Modern Node.js Can Do That?

Modern Node.js Can Do That?

Published

If you, like me, learned Node.js many years ago you might, also like me, occasionally miss out on all the amazing new features and capabilities it gained over all those years.

Sure, ESM support landed (and is becoming the default) … but beyond that? Well, there’s actually a lot!

Especially since the release (and pressure applied) by Bun and Deno, Node.js has gained a stunning amount of new features - along with countless performance and security updates as well as bug fixes.

Node.js therefore might be in its best shape ever. And there’s lots of reason to expect this dynamic to continue!

Built-in Watch Mode

Working on a Node.js web server? You need nodemon for development, right? Because who wants to constantly restart the Node server manually?

Well … turns out: You don’t!

Added in Node.js 18.11.0, stable since 22.0.0, Node.js offers the --watch flag to start a program in “Watch Mode”.

As the name suggests, “Watch Mode” watches your project file system for changes and automatically restarts your running Node program whenever a change is detected (e.g., if you saved a file after editing it).

By default, --watch watches the program entry point (e.g., app.js) and any required or imported files.

If you need more control over which files should be watched, you can use --watch-path instead of --watch. --watch-path accepts a path (e.g., --watch-path=./src) and can be added multiple times to watch multiple paths (and only those).

node —run

It’s quite common to define various scripts in the "scripts" section of your package.json file. Those can be executed via npm run <script-name>.

You already know that.

Since Node 22, you can now, alternatively, also invoke those scripts via node --run <script-name> (so: using node instead of npm).

Why would you do that?

Performance … speed! According to the Node.js team, you can potentially run commands a ~100-200ms quicker using node --run instead of npm run.

Of course that’s not necessarily a game changer but still nice to have, I guess.

Built-in TypeScript Support

Using TypeScript for your Node.js project?

That means installing the @types/node package and setting up a compilation workflow for your application.

Unless … you’re using Node.js 22.6 or higher! That allows you to get rid of the second part (compilation), at least.

Node.js 22.6 introduced experimental (!) type stripping. Which means, it’ll not compile your TypeScript code to JavaScript but instead simply remove (or ignore) all TypeScript-specific syntax.

So this code:

function add(n1: number, n2: number): number {
  return n1 + n2;
}

would be executed like this:

function add(n1, n2) {
  return n1 + n2;
}

You can therefore run TypeScript files via Node.js like this:

node app.ts

When using Node.js 23.6+, type stripping is enabled by default.

Why does Node.js perform type-stripping instead of compilation?

For performance and complexity reasons. Implementing a full-fledged TypeScript compiler into Node.js would increase the size and decrease the performance of the Node program. It would also introduce quite a bit of complexity.

Because of Node.js stripping types, instead of compiling TS code to JS code, there are couple of limitations and requirements for your code:

The first point (not all TS features can be used) may sound severe, but it turns out that only a small subset of TS features are affected. Chances are that you’re not needing them.

When using TypeScript 5.8+, you can (and should) set the erasableSyntaxOnly option to true in the tsconfig.json file. This will produce a warning whenever you’re trying to use an unsupported TypeScript feature in your code.

If you need to use un-strippable features, you can add the --experimental-transform-types flag to the node command. At a performance cost, this will transform TypeScript-only syntax (like enums) to JavaScript code.

Finally, it’s worth highlighting that as of Node.js 23, this feature is still experimental - hence it may change over time. Bugs can also occur, of course.

Built-in SQLite Support

SQLite is an amazing database engine that allows you to add a SQL database to any project without setting up, configuring, running and paying an external SQL server.

By the way: SQLite is not just for your hobby project

So how do you use it in a Node.js application? Probably via one of the many available third-party packages like better-sqlite3 (which is amazing btw).

But you don’t have to!

Since Node.js 22.5, Node has built-in support for SQLite - via its sqlite module!

You can connect to and use a SQLite database like this, for example:

import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync('my-db.db');

db.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY,
    email TEXT UNIQUE,
    password TEXT
  )
`);

const insertStmt = db.prepare(
  'INSERT INTO users (email, password) VALUES (?, ?)'
);

function addUser(email, hashedPw) {
  insertStmt.run(email, hashedPw);
}

At the time of this writing, the sqlite module is still under “active development” and its implementation therefore might change. There also may be bugs.

But it’s amazing to have one less external dependency needed when using a database in your Node.js project!

Promise setTimeout() & setInterval()

Ever wrote code like this?

function wait(time) {
  return new Promise((resolve) => setTimeout(resolve, time));
}

I have! But just because I wasn’t paying attention to Node.js releases!

Since Node.js 15 (which was released in 2020!) I could’ve used Node’s built-in timers Promises API like this:

import { setTimeout } from 'node:timers/promises';

await setTimeout(3000, 'some value');

// do something else once the timer expired...

Of course, that same module also exposes setInterval and setImmediate as Promises.

So, don’t be me - be smarter and use those built-ins!

Built-in Test Runner

Did you know that you don’t necessarily have to use Vitest or any other test runner? Of course, you might want to use a third-party library because of its features, API or performance. But you don’t have to!

Node.js, since version 20, has stable support for the built-in --test flag that allows you to run tests like this:

import { test, mock } from 'node:test';
import assert from 'node:assert';
import fs from 'node:fs';

import { split } from 'utils/string.js';

mock.method(fs, 'readFile', () => 'Test content');
test('read file and split its content', async () => {
  const content = fs.readFile('test.txt');
  assert.strictEqual(split(content)[0], 'Test');
  assert.strictEqual(split(content)[1], 'content');
});

.env Files without dotenv

During development, it’s quite common to store environment variables in .env files.

Prior to Node 20.6.0, you needed to use packages like dotenv to load these files and their environment variables.

Since Node 20.6.0, you can instead use Node’s built-in .env file support by adding the --env-file flag to the node command.

Like this:

node --env-file=.env my-app.js

Web Storage API (localStorage)

Accidentally executed some code that uses localStorage with Node.js?

Prior to Node.js 22.4 you would get an error. Okay, actually, you’ll still get an error with Node.js 22.4 or higher, if you’re running your program without the --experimental-webstorage and --localstorage-file flags - which you probably do.

But when setting those flags, you can actually use localStorage and sessionStorage in your Node.js application to store data (max 10mb) in a file (and, of course, retrieve it from there).

Just be careful: The data will be stored in files, not in the browser. And it is shared across requests and users (when building a web server).

But if you need to store some (temporary) data, using localStorage may be the easier alternative to using Node’s fs module.

Safe Value Sharing via AsyncLocalStorage

Potentially even more useful than Node’s support localStorage is its AsyncLocalStorage feature.

First introduced in Node.js 13.10, AsyncLocalStorage can be used to store data in memory throughout the lifetime of a web request or other async operations.

You could, for example, use this API to store information about an authenticated user (e.g., the email address and id) in one middleware function and use it in another one (or some route handler function).

import { AsyncLocalStorage } from 'node:async_hooks';

import express from 'express';

import { authenticateUser } from './auth.js';

const userStorage = new AsyncLocalStorage();
const userStore = new Map(); // store can be anything! Maps, sets, arrays, objects, ...

const app = express();

app.use(async (req, res, next) => {
  const { email, id } = authenticateUser(req);
  await userStorage.run(userStore, () => {
    userStorage.getStore().set('id', id);
    userStorage.getStore().set('email', email);
  });
  next();
});

app.get('/dashboard', async (req, res) => {
  await userStorage.run(userStore, () => {
    const data = userStorage.getStore();
    res.render('dashboard', { email: data.get('email'), id: data.get('id') });
  });
});

app.listen(3000);

Fetch API

This is probably one of the more well-known additions to Node.js - you can use the fetch() API as you know (and love? hate?) it from the browser in Node.js projects. At least when using Node.js 18 or higher.

Hence, if you need to send an HTTP request from inside your Node.js application, you can now do so like this:

const response = await fetch('some-other-site.com', {
  method: 'POST',
});

const data = await response.json();

You can learn more about fetch() on its MDN documentation page .

Top-level Await

Okay, you probably also already know this feature.

But still, it’s worth mentioning it here since it’s awesome!

Node.js does not just allow you to use async / await instead of then() (since version 7.6) - it instead even allows you to use await outside of an async function to run code like this:

await someAsyncTask();

// do something else

Top-level await is supported since Node.js 14.8 and only when using inside of ESM files (.mjs or if enabled via "type": "module" in package.json).

Quick File Search With glob

Depending on what you’re building, you might need to find and use files (and folders) that may be stored somewhere in your Node.js project or any other place+ on your machine.

With Node.js 22 this got a lot easier! You can use the new glob API to find all files and folders matching a pattern defined by you.

For example, if you need to find all Markdown (.md) files in a given folder and its subfolders in your project, you could use the following code:

import { glob } from 'node:fs/promises';

for await (const fileOrFolder of glob('articles/**/*.md')) {
  // do something with file
  // for example, parse content, convert to HTML, prepare for sending with a response, ...
}

By default, the pattern specified for glob will be executed in your process.cwd() (current working directory).

Built-in Websockets Client

Whilst you still need extra libraries to set up a Node.js Websocket server (or write lots of code from scratch), at least connecting to other Websocket servers from inside your Node.js application got easier with Node.js 22!

Because that version ships stable support for the web Websocket client API as documented on MDN .

This means that, if you need to reach out to some other Websocket server from inside your Node.js app, you can now do so like this (when using at least Node.js version 22):

const socket = new WebSocket('ws://localhost:8080');

socket.addEventListener('open', (event) => {
  socket.send('Sending to other server...');
});

socket.addEventListener('message', (event) => {
  console.log('Received message from server: ', event.data);
});

If you instead aim to build a Node.js-powered Websocket server, you’ll either have to write all the code from scratch or reach for solutions like ws or Socket.io .

For example, using ws (npm install ws), you can quickly set up a Node.js Websocket server like this:

import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });

wss.on('connection', function connection(ws) {
  ws.on('error', console.error);

  ws.on('message', function message(data) {
    // handle messages sent from client
    console.log('Message received: ', data);
  });

  ws.send('Message from server!'); // send to client
});