
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:
- You can’t use TS features that rely on compilation and can’t simply be stripped out - most notably enums
- Imports of other
.ts
files must contain the.ts
extension in the import statement (e.g.,import something from './some-file.ts'
) - Imports of types (instead of functions, variables, classes etc) must be marked via the
type
keyword (e.g.,import { type User } from './my-types.ts'
)
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
});