What Node.js Looks Like in 2026 (and Why It Still Runs Half the Web)

Node.js in 2026 is a mature runtime that absorbed most of what you used to npm install: global fetch, node:test, node:sqlite, --env-file, and a permission model. The newest LTS is usually all you need, so fewer dependencies and less patching at 11pm.
Part of the complete web development guide.
On this page
- The runtime grew up
- Async code: stop wrapping things that don’t need wrapping
- Streams and backpressure are still where people get burned
- Don’t block the event loop
- Structure: boring is good
- Testing without a framework tax
- Deployment reality in 2026
- What I’d actually tell you to do
- FAQ
- Should I use Express or Fastify in 2026?
- Is it worth migrating off an old Node version?
- Do I need TypeScript for a Node backend?
Node.js has been around since 2009, and it’s still the default choice for a huge amount of backend and tooling work. But the runtime you’re running today is not the runtime you learned in 2018. Here’s what actually changed, what it means for your code, and how to keep a long-lived Node project from rotting.
The runtime grew up
For years, the honest answer to “what version of Node should I use” was “the newest LTS, and install a pile of npm packages to fill the gaps.” That’s no longer true. A lot of what used to require node-fetch, node-fetch wrappers, dotenv, node-cron, or a test runner has been absorbed into the standard library.
A few things that are just built in now:
- fetch, Web Streams, and
AbortControllerare global. No polyfill, no import. - node:test ships a real test runner with mocking and coverage.
- node:sqlite gives you a synchronous SQLite driver with zero dependencies.
- –env-file reads a
.envfile natively. - Permission model (
--permission) lets you restrict filesystem, child process, and network access. - Single executable applications let you ship a Node app as one binary.
None of that means “delete your dependencies.” It means the baseline got stronger, and every dependency you don’t add is a dependency you don’t have to patch at 11pm.
Async code: stop wrapping things that don’t need wrapping
The most common thing I still see in code reviews is unnecessary promise plumbing around APIs that already return promises or accept callbacks cleanly. Modern Node wants you to use the built-in promise APIs and async/await directly.
import { readFile, writeFile } from 'node:fs/promises';
import { setTimeout as sleep } from 'node:timers/promises';
async function processConfig(path) {
const raw = await readFile(path, 'utf8');
const config = JSON.parse(raw);
// Retry a flaky upstream call with backoff instead of a callback pyramid.
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const res = await fetch(config.endpoint, {
signal: AbortSignal.timeout(5000)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
if (attempt === 3) throw err;
await sleep(2 ** attempt * 100);
}
}
}
Two small things in there worth calling out: AbortSignal.timeout() means you never write a manual timer to kill a hung request again, and node:timers/promises gives you a sleep that doesn’t block anything.
Streams and backpressure are still where people get burned
If your service handles uploads, log pipelines, or large exports, streams matter. The failure mode is always the same: you read a big source into memory, do something to it, and write it out, and it works fine until a file is bigger than your container’s memory limit.
Use pipeline. It handles cleanup and error propagation so you don’t leak file handles.
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
import { pipeline } from 'node:stream/promises';
await pipeline(
createReadStream('access.log'),
createGzip(),
createWriteStream('access.log.gz')
);
If you’re transforming records, Transform streams plus pipeline give you backpressure for free. The old data event pattern does not.
Don’t block the event loop
Node runs your JavaScript on one thread. Anything CPU-heavy (image resizing, big JSON parsing, crypto with the wrong API, regex that backtracks) stalls every other request on that process. This is the number one cause of “the API is slow sometimes and we can’t figure out why.”
Options, roughly in order of preference:
- Move the work to a
worker_threadspool. - Use a native addon or a service in a language that’s better at that specific job.
- Use the async crypto APIs (
crypto.subtle) instead of the sync ones. - If it’s parsing, stream it instead of
JSON.parseon a 200MB string.
You can also just measure. node --cpu-prof app.js writes a profile you can open in Chrome DevTools. That’s usually the fastest way to find the culprit.
Structure: boring is good
The projects that survive five years all look similar. They have a thin HTTP layer, business logic in plain functions that don’t know about HTTP, and data access behind an interface. Frameworks come and go (Express, Fastify, Hono, whatever’s next), but the shape of the code shouldn’t change when you swap one out.
Two habits that pay off:
- Keep the entry point dumb.
server.jswires things up and callslisten(). Nothing else. - Validate at the boundary. Zod, Valibot, or the built-in schema stuff in newer runtimes. Never trust a request body, ever.
I’ve inherited enough Node codebases on Long Island and elsewhere to know that the difference between a project you can hand off and one you can’t is almost never the framework. It’s whether the logic is testable in isolation. If you’re weighing a rebuild, it’s worth talking to someone who does custom web development day to day rather than guessing at it.
Testing without a framework tax
node:test is good enough for most projects now. It runs with node --test, supports subtests, and has assertions and mocking built in.
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
import { parseOrder } from './orders.js';
test('rejects orders with no line items', () => {
assert.throws(() => parseOrder({ id: 1, items: [] }), /at least one item/);
});
test('calls the payment gateway once', async () => {
const charge = mock.fn(async () => ({ ok: true }));
await parseOrder({ id: 1, items: [{ sku: 'A1' }] }, { charge });
assert.equal(charge.mock.callCount(), 1);
});
Fewer moving parts, no config file, and it’s the same runner in CI and locally.
Deployment reality in 2026
Containers are still the default, but two other options are genuinely viable now. Single executable applications let you compile a Node app into one binary, which is handy for CLI tools and small services where you don’t want a runtime install step. And edge runtimes have matured, though they run a subset of Node’s API, so check before you commit to one.
Whatever you pick, pin your Node version in .nvmrc or the engines field, and make CI use the same one. “Works on my machine” is a version mismatch nine times out of ten.
What I’d actually tell you to do
- Run the current LTS. Upgrade once a year, not once every four.
- Reach for the standard library before npm.
- Never block the event loop. Profile before you optimize.
- Keep business logic out of route handlers.
- Set up structured logging (
pinois fine) and real error tracking from day one.
Node isn’t exciting anymore, and that’s the point. It’s a stable, well-understood tool. The teams that get the most out of it are the ones treating it like infrastructure instead of a playground.
FAQ
Should I use Express or Fastify in 2026?
Both work. Fastify is faster and has schema validation built in. Express 5 is finally stable and has a massive ecosystem. Pick based on your team’s familiarity, not benchmarks. If you’re starting fresh and don’t have a strong reason otherwise, Fastify is a reasonable default.
Is it worth migrating off an old Node version?
Yes, especially if you’re more than two LTS releases behind. You’re missing security patches and built-in features that would let you delete dependencies. Budget a week for a typical mid-size service, more if you have native modules.
Do I need TypeScript for a Node backend?
Not required, but it’s the norm now. It catches a whole class of bugs at build time and makes refactoring less scary. If you’re maintaining a small script, plain JS is fine. If it’s a service other people touch, use TypeScript.


