From databases to real-time communication without npm install — how Node.js 24 has restructured backend dependencies
I remember spending two hours tracking down a better-sqlite3 native binary build failure in CI during a team project last year. Build environment differences, Python versions, compiler versions... honestly, after that day I kept thinking "is this really worth it just to use SQLite?" Then, while skimming the Node.js 24 release notes, I spotted node:sqlite landing properly — and felt genuinely relieved.
Node.js 24 was released as Current on May 6, 2025, and is scheduled to transition to LTS in October 2025. One thing worth noting: most of the features covered in this post were actually introduced in the Node.js 22 line and landed properly in 24.
node:sqlite: introduced in 22.5.0 (July 2024), still Stability 1 (Experimental) in 24 — but available by default with no flag- Built-in
WebSocket: enabled without a flag in 22.4.0, stable in the latter half of 22 require(esm): unflagged in 22.12.0 (December 2024)- Permission Model: promoted to stable with
--permissionin 23.5.0
Node.js 24 essentially placed this trajectory on a stable orbit. Node.js is seriously catching up to the "Batteries Included" strategy pioneered by Deno and Bun, and these four changes alone let you drop better-sqlite3, ws, and even some Babel configuration.
This post focuses on how each feature actually works, when it's a good fit, and when existing tools are still the better choice. I'll be candid about caveats and pitfalls too.
Core Concepts
node:sqlite — SQLite Without a Native Build
node:sqlite is a built-in module that lets you use SQLite without installing third-party packages like better-sqlite3 or sqlite3. One thing to say upfront: as of Node.js 24, this module is still documented as Stability 1 (Experimental). It's enabled by default so no extra flag is needed, but the API may change in minor ways — something to factor in before adopting it in production.
There are two core classes.
DatabaseSync: the database connection object, accepting:memory:or a file path.StatementSync: a prepared statement created withdb.prepare(), executed via.run(),.get(), or.all().
The entire API is synchronous. You get results back immediately without await, which keeps the code intuitive.
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE
)
`);
const insert = db.prepare('INSERT INTO users (name, email) VALUES (?, ?)');
insert.run('Alice', 'alice@example.com');
const select = db.prepare('SELECT * FROM users WHERE name = ?');
const row = select.get('Alice');
console.log(row); // { id: 1, name: 'Alice', email: 'alice@example.com' }
db.close();There is no build step at all. Nothing to add to package.json. You can strip build tools like python3, make, and g++ from your Docker images, and the native compilation step disappears from your CI pipeline.
For advanced SQLite tuning, call PRAGMA directly.
db.exec('PRAGMA journal_mode=WAL');
db.exec('PRAGMA synchronous=NORMAL');require(esm) Stabilization — The Wall Between CJS and ESM
I was confused by this at first too, so let me set the context for exactly what require(esm) solves.
The Node.js ecosystem has long suffered a dual pain point between CommonJS (require) and ES Modules (import). When popular packages like chalk, unified, and remark switched to ESM-only, using them from a CJS codebase meant hitting ERR_REQUIRE_ESM errors or working around them with dynamic import().
require(esm), unflagged in Node.js 22.12.0 and fully settled in 24, solves this. ESM modules that do not contain top-level await can be loaded directly with require() from CJS files.
// Before: ERR_REQUIRE_ESM error
// Node.js 22.12+ / 24: works fine
const { unified } = require('unified'); // ESM-only package
const remarkParse = require('remark-parse');The flowchart below shows when you can use require() and when dynamic import() is still needed.
One thing to watch: check whether the package supports the "module-sync" export condition. The check is simple — open the package's package.json and look for a "module-sync" condition in the exports field.
{
"exports": {
".": {
"module-sync": "./dist/index.js",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}If this condition is present, you can safely load it with require() from CJS. Most actively maintained ESM packages have started adding this condition, but some older ESM-only packages haven't yet — if you run into trouble, check here first.
Client-Side Built-in WebSocket
The WebSocket global class was enabled without a flag in Node.js 22.4.0 and is available by default in 24. It follows the WHATWG WebSocket standard, so code you wrote for the browser runs in Node.js as-is.
Two important points. First, WebSocket is a global class, so no import is needed. Second, the built-in WebSocket is client-only (the connecting side). You need a separate solution for the server side.
// No import needed — WebSocket is a global class
const ws = new WebSocket('ws://localhost:8080');
ws.addEventListener('open', () => {
ws.send('Hello from Node.js');
});
ws.addEventListener('message', ({ data }) => {
console.log('Received message:', data);
});
ws.addEventListener('close', () => {
console.log('Connection closed');
});For server-side WebSocket, handling the upgrade event from node:http directly or continuing to use the ws package is the practical approach. If you need complex features like room management, namespaces, or fallbacks, Socket.IO remains a solid choice.
Permission Model — Process-Level Permission Isolation
The Permission Model started in Node.js 20 as --experimental-permission and was promoted to stable with the --permission flag in 23.5.0. It lets you restrict filesystem access, child processes, worker threads, native addons, and WASI at the process level.
Let me clear up a common misconception here. Unlike Deno, the Node.js Permission Model does not provide network access control (--allow-net). The flags supported in the Node.js documentation are:
--allow-fs-read,--allow-fs-write--allow-child-process--allow-worker--allow-addons--allow-wasi
If you want to restrict network access, you need a separate firewall, proxy, or application-level allowlist. Confusing this point leads to serious headaches in practice, so keep it in mind.
# Read access for all of /app, write access for /app/uploads only, child processes and workers/addons all blocked
node --permission \
--allow-fs-read=/app \
--allow-fs-write=/app/uploads \
server.jsEnabling --permission alone blocks all filesystem access, child process creation, worker threads, and native addon loading not explicitly listed in the flags above.
At runtime, use process.permission.has() to check the current permission state. The categories this API supports match the list above.
if (!process.permission.has('fs.read', '/etc/passwd')) {
console.log('Sensitive file access blocked');
}
if (process.permission.has('fs.write', '/app/uploads')) {
// Write to the uploads directory
}
if (!process.permission.has('child')) {
console.log('Cannot create child processes — exec/spawn calls will throw ERR_ACCESS_DENIED');
}Practical Applications
Scenario 1: Lightweight SQLite for Config and Cache Storage
Test fixtures, dev environment config storage, temporary caches — spinning up PostgreSQL for these is overengineering. node:sqlite is a perfect fit.
The example below already incorporates the "practical pitfall" covered in the next section, hooking db.close() into process shutdown.
import { DatabaseSync } from 'node:sqlite';
import { join } from 'node:path';
const db = new DatabaseSync(join(process.cwd(), 'config.db'));
db.exec('PRAGMA journal_mode=WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER DEFAULT (unixepoch())
)
`);
const upsert = db.prepare(`
INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE
SET value = excluded.value,
updated_at = unixepoch()
`);
const get = db.prepare('SELECT value FROM settings WHERE key = ?');
export function setSetting(key, value) {
upsert.run(key, JSON.stringify(value));
}
export function getSetting(key, fallback = null) {
const row = get.get(key);
return row ? JSON.parse(row.value) : fallback;
}
// Prevent stale WAL files — explicit close on process exit
let closed = false;
function shutdown() {
if (closed) return;
closed = true;
db.close();
}
process.on('exit', shutdown);
process.on('SIGINT', () => { shutdown(); process.exit(0); });
process.on('SIGTERM', () => { shutdown(); process.exit(0); });The API is nearly identical to better-sqlite3, so migration is straightforward.
Scenario 2: Using ESM-Only Packages in a CJS Codebase
You need to use a Markdown processing pipeline that has switched to ESM-only, inside a legacy Express app. Before Node.js 22.12, you'd have to work around this with dynamic import(). Now you can just use require().
One thing to watch: the remark package is already a complete processor combining unified + remark-parse + remark-stringify. It is not a plugin to pass into unified().use(remark). The correct pattern is one of the two below.
// routes/docs.js (CommonJS)
'use strict';
// Option A: use the remark processor
const { remark } = require('remark');
const remarkHtml = require('remark-html');
function markdownToHtmlA(markdown) {
return String(remark().use(remarkHtml).processSync(markdown));
}
// Option B: compose unified directly
const { unified } = require('unified');
const remarkParse = require('remark-parse');
function markdownToHtmlB(markdown) {
const processor = unified().use(remarkParse).use(remarkHtml);
return String(processor.processSync(markdown));
}
module.exports = { markdownToHtml: markdownToHtmlA };This works without a transpilation layer or extra flags. It's a good opportunity to simplify or eliminate your existing Babel configuration.
Scenario 3: Microservice Isolation with Permission Model
This example applies the principle of least privilege to a service that handles logs and uploaded files and never needs to run external commands. Since Permission Model doesn't control the network, we explicitly allowlist/blocklist the filesystem, child processes, worker threads, and addons.
{
"scripts": {
"start": "node --permission --allow-fs-read=/app/src,/app/node_modules,/app/config --allow-fs-write=/app/logs,/app/uploads src/server.js"
}
}The config above omits --allow-child-process, --allow-worker, and --allow-addons, so any attempt by the service to call child_process.spawn, worker_threads.Worker, or load a C++ addon will immediately throw ERR_ACCESS_DENIED.
// src/server.js
import { createServer } from 'node:http';
const required = [
['fs.read', '/app/src'],
['fs.read', '/app/config'],
['fs.write', '/app/logs'],
];
for (const [perm, resource] of required) {
if (!process.permission.has(perm, resource)) {
console.error(`Missing required permission: ${perm} → ${resource}`);
process.exit(1);
}
}
// This service should never need child processes
if (process.permission.has('child')) {
console.warn('Warning: child process permission is open — review deployment scripts');
}
const server = createServer((req, res) => {
// Service logic
});
server.listen(3000);This is also useful for mitigating supply chain attacks. If a malicious transitive dependency tries to read /etc/passwd, write files outside /tmp, or spawn a child process to run a curl | sh payload, Permission Model in active state will block it with ERR_ACCESS_DENIED. That said, if you also want to block attempts to exfiltrate data over the network, you'll need a separate outbound firewall or proxy allowlist.
Pros and Cons
node:sqlite
| Item | Details |
|---|---|
| Pros | No native build required, saves node_modules space, simplifies CI/CD, intuitive synchronous API |
| Cons | Still Experimental as of 24, synchronous API can block the event loop under heavy queries, advanced tuning requires direct PRAGMA calls |
| Recommended for | Config storage, temporary caches, test fixtures, standalone development DB |
| Not recommended for | Large-scale production DB (PostgreSQL is better), environments with high concurrent write load |
Common real-world mistakes
- Forgetting
db.close():DatabaseSyncmust be closed explicitly. For file-based DBs, WAL files can be left behind when the process exits. Hookprocess.on('exit', ...),SIGINT, andSIGTERMtogether as shown in Scenario 1. - Re-creating prepared statements inside a loop: Call
db.prepare()once outside the loop and reuse the result. Preparing inside the loop on every iteration can actually make things slower. - Not enabling WAL mode: The default journal mode has poor read-write concurrency. For file-based DBs, set
PRAGMA journal_mode=WALat startup.
Built-in WebSocket
| Item | Details |
|---|---|
| Pros | Identical to the browser API, eliminates the ws client, easy code reuse |
| Cons | Client-only; server-side WebSocket requires a separate implementation |
| Recommended for | Client code connecting to an external WebSocket server, real-time server-to-server communication |
| Not recommended for | Server-side WebSocket handling, when room management or namespaces are needed (consider keeping Socket.IO) |
Common real-world mistakes
- Trying to import from a non-existent module: Code like
import { WebSocket } from 'node:websocket'doesn't work.WebSocketis a global class — usenew WebSocket(url)directly without any import. - Confusing server and client: Many people get stuck trying to build a server with the built-in WebSocket. Servers need to be implemented using the
upgradeevent fromnode:httpor thewspackage.
require(esm) Stabilization
| Item | Details |
|---|---|
| Pros | Resolves ERR_REQUIRE_ESM, allows immediate use of modern ESM packages from legacy CJS, simplifies the build pipeline |
| Cons | ESM with top-level await still requires dynamic import(), potential issues with older packages that don't support "module-sync" |
| Recommended for | Using ESM-only packages in a legacy CJS codebase, simplifying Babel configuration |
| Not recommended for | Packages with initialization logic that depends on top-level await |
Common real-world mistakes
- Assuming all ESM can be
require()d: ESM withtop-level awaitstill requires dynamicimport(). Get in the habit of checking whetherawaitappears at the top level of the package source. - Not checking older package compatibility: Look in the package's
package.jsonfor a"module-sync"condition in theexportsfield. Without it,require()may fail.
Permission Model
| Item | Details |
|---|---|
| Pros | Process-level isolation, blocks file and process payloads from supply chain attacks, enables zero-trust-oriented architecture |
| Cons | Network control is out of scope (needs separate tooling), legacy packages that assume broad filesystem access become a pain to operate, initial permission list discovery takes time |
| Recommended for | Microservices with many external packages, security-sensitive environments, containerized deployments |
| Not recommended for | Rapid prototyping phase, applying directly to a legacy monolith |
Common real-world mistakes
- Setting
--allow-fs-read=*too broadly: That defeats the purpose of enabling Permission Model. Specifying precise paths with the principle of least privilege is the whole point. - Expecting network blocking: As emphasized earlier, Node.js Permission Model does not control the network. If controlling external communication is the goal, pair it with a firewall, outbound proxy, or service mesh policy.
- Discovering child process needs too late: If a library calls
child_process.execafter you deploy with--permissionbut without--allow-child-process, it fails at runtime. Before rolling out, run integration tests in staging to trace actual call paths.
How Far Can You Realistically Reduce Dependencies with Built-ins?
To summarize: node:sqlite gives you a database without a native build, the built-in WebSocket gives you a real-time client without third-party packages, require(esm) bridges the CJS/ESM boundary, and Permission Model gives you process-level isolation. Most of this, however, is a trajectory that started in Node.js 22 and finalized in 24 — and some parts, like node:sqlite, are still Experimental.
There's no need to change everything at once. Large-scale production databases are still better served by PostgreSQL, complex WebSocket servers are easier with the ws package, and network access control is outside Permission Model's scope. But if you're starting a new project or have an opportunity to trim your dependencies, the built-in features are worth trying first.
Where to start
- Adopt
node:sqlitelocally: Try it first for test fixtures or dev environment config storage. The API is nearly identical tobetter-sqlite3, so migration cost is low. If the Experimental label worries you, start with dev-only use. - Validate
require(esm)in legacy code: Try loading packages that used to throwERR_REQUIRE_ESMwith a plainrequire()in Node.js 24. If the package has notop-level awaitand includes the"module-sync"condition, it will almost certainly just work. - Apply Permission Model in staging: Spin up your service in staging with
--permissionand a minimal set of--allow-fs-*flags, then run integration tests to surface failure points. Finalize the permission list there, then promote to production.
References
- Node.js 24.0.0 Official Release Notes
- OpenJS Foundation — What's New with Node.js 24
- Node.js Official Docs — SQLite API (node:sqlite)
- Node.js Official Docs — Permissions & Permission Model
- Node.js Official Docs — WebSocket (Learn)
- Joyee Cheung — require(esm) in Node.js: from experiment to stability
- Joyee Cheung — require(esm) in Node.js: implementer's tales
- AppSignal Blog — What's New in Node.js 24
- Better Stack — Getting Started with Native SQLite in Node.js
- LogRocket — Using the built-in SQLite module in Node.js
- LogRocket — 10 Node.js 24 features you're probably not using
- Stackademic — Native WebSocket Support in Node.js 24
- NodeSource — Node.js 24 Is Here: What You Need to Know
- Red Hat — An introduction to Node.js 24