Development Tips

Node.js Development Tips That Actually Hold Up in 2026

By Ken Key Mar 29, 2025 5 min read Updated Oct 16, 2026

Node.js Development Tips That Actually Hold Up in 2026
The short answer

Run Node 24 with native TypeScript and type stripping. Write ESM, skip the build step, and let tsc check types in CI. Use the built in test runner and node: prefixed imports. Pin your runtime version and keep dependencies small.

Part of the complete web development guide.

On this page

Most Node.js advice floating around is either five years stale or written by someone who has never had to page themselves at 2am. Node has changed a lot since the old days of callback pyramids and Babel-everything. Here is what I actually use and what I actually warn people about when I’m building backends in 2026.

Native TypeScript is finally the default

Node 22.6 shipped experimental type stripping, and by Node 24 it’s stable enough that I run .ts files directly in most new projects. No build step, no ts-node, no tsx wrapper. You get the types checked by tsc in CI, and Node just strips the annotations at runtime.

What it does not do is transform enums, namespaces, or anything that needs a compiler to emit code. That’s a deliberate limit. If you need those, you still want a real build step. For the 90% case (types, interfaces, generics), it just works:

// server.ts
import { createServer } from "node:http";

interface Health {
 status: "ok" | "degraded";
 uptime: number;
}

const start = Date.now();

const server = createServer((req, res) => {
 if (req.url === "/health") {
 const body: Health = {
 status: "ok",
 uptime: Math.floor((Date.now() - start) / 1000),
 };
 res.writeHead(200, { "content-type": "application/json" });
 res.end(JSON.stringify(body));
 return;
 }
 res.writeHead(404).end();
});

server.listen(3000);

Run it with node server.ts and it works. In your package.json, add "type": "module" and stop fighting CommonJS interop in new code. The ecosystem has caught up. Libraries that still ship CJS-only are the exception now, not the rule.

Stop reaching for Jest by reflex

The built-in test runner (node:test) is good. It has watch mode, coverage via --experimental-test-coverage (stable as of Node 22), mocking, and snapshot support. For a typical backend it removes three dependencies and a config file.

import { test, describe } from "node:test";
import assert from "node:assert/strict";

describe("parsePrice", () => {
 test("handles cents", () => {
 assert.equal(parsePrice("19.99"), 1999);
 });

 test("rejects garbage", () => {
 assert.throws(() => parsePrice("free"));
 });
});

Run it with node --test and it discovers files matching *.test.ts or *.test.js. That’s it. I still reach for Vitest when a project is heavy on browser-ish code or needs the richer mocking API, but for server logic the built-in runner has been my default for a while now.

The CPU-bound trap (and worker threads)

Node’s single-threaded event loop is great until you do something expensive in a request handler. Hashing a password with a low work factor, resizing an image, parsing a giant CSV, running a regex against untrusted input: any of these will block every other request behind them. I’ve seen this take down production more than any other Node mistake.

Two fixes. First, push the work off the main thread:

import { Worker } from "node:worker_threads";

function resizeInWorker(buffer: Buffer): Promise<Buffer> {
 return new Promise((resolve, reject) => {
 const worker = new Worker(new URL("./resize-worker.ts", import.meta.url));
 worker.postMessage(buffer);
 worker.once("message", resolve);
 worker.once("error", reject);
 });
}

Second, for anything that’s genuinely CPU-heavy or long-running, don’t do it in Node at all. A small queue plus a worker process in a language that’s good at that job is often simpler than fighting the runtime. I’ve shipped both approaches depending on the team’s stack.

Async patterns that don’t shoot you later

  • Always await or return a promise. A floating promise is a bug waiting to surface as an unhandled rejection at the worst possible moment. Node’s --unhandled-rejections=strict is the default now; don’t disable it.
  • Use AbortController for timeouts. fetch is built in, but it has no default timeout. Wrap it.
  • Don’t mix callbacks and promises in the same layer. Pick one per module and stick to it.
  • Prefer Promise.allSettled over Promise.all when one failure shouldn’t kill the batch.
async function fetchWithTimeout(url: string, ms = 5000) {
 const controller = new AbortController();
 const timer = setTimeout(() => controller.abort(), ms);
 try {
 const res = await fetch(url, { signal: controller.signal });
 if (!res.ok) throw new Error(`HTTP ${res.status}`);
 return await res.json();
 } finally {
 clearTimeout(timer);
 }
}

That finally block matters. Forget it and you leak timers under load.

Streams and backpressure

If you’re reading files or proxying responses, use streams and let Node handle backpressure. The classic mistake is fs.readFile on a user-uploaded file, then buffering the whole thing in memory to process it. For anything over a few megabytes, stream it. pipeline from node:stream/promises handles cleanup and error propagation better than chaining .pipe() calls by hand.

import { pipeline } from "node:stream/promises";
import { createReadStream, createWriteStream } from "node:fs";
import { createGzip } from "node:zlib";

await pipeline(
 createReadStream("input.log"),
 createGzip(),
 createWriteStream("input.log.gz")
);

Deployment notes for 2026

Container images: use the official node:24-slim base, not the full image. Multi-stage builds where you install dev deps in one stage, copy node_modules into a lean runtime stage. If your app is pure JS or native-TS, you can skip the build step entirely and cut image size and build time.

Memory: set --max-old-space-size to something below your container limit so the runtime GCs before the OOM killer shows up. If you’re on a managed platform that sets this for you, leave it alone.

Logging: structured JSON, one line per event, with a request ID. console.log with an object is fine. You don’t need a logging library for most apps.

Where this fits with the rest of your stack

Node is great at APIs, real-time stuff (WebSockets, SSE), and glue between services. It’s less great at CPU-heavy batch work. If you’re building something that mixes a Node backend with a real frontend and you’d rather not assemble it all yourself, that’s the kind of thing I do through custom web development for clients around Commack and across Long Island, and the same patterns apply whether you’re local or not.

FAQ

Should I still use Express in 2026?

Yes, it’s fine, and it’s still the most familiar option. But for new projects, look at Fastify (faster, better TypeScript types) or just the built-in node:http if your needs are simple. Express 5 finally landed and fixed a lot of the old async error handling pain, so if you’re already on it, upgrading is worth the afternoon.

Do I need a build step for a Node backend now?

Usually no. With native type stripping, node app.ts runs directly. You still want tsc --noEmit in CI to actually type-check, since Node doesn’t do that at runtime. Add a bundler only if you’re shipping to a serverless platform with strict size limits or need tree-shaking.

What’s the single biggest Node mistake you see in production?

Blocking the event loop with synchronous work in a request path. It doesn’t show up in dev, it doesn’t show up in light load testing, and then it takes down the service under real traffic. Profile with node --prof or a flamegraph tool before you ship anything that touches the filesystem, crypto, or large data in a handler.

Have a project like this?

I do custom web development for Long Island businesses. If you need this done right, let’s talk.