Node.js & Asynchronous JavaScript: Evaluating Backend JS for Full-Stack Roles

Where Node's async model actually saves you time, where the "one language" hiring pitch is true, and the exact point where Node stops being the right runtime.
One thread runs your JavaScript; a pool you never see runs everything else
Node.js does not have "one thread." It has one thread for your JavaScript — the event loop — and a separate pool of k worker threads, managed by libuv, that handle the I/O your JavaScript can't do itself. The default size of that pool is four threads, controlled by the UV_THREADPOOL_SIZE environment variable, and it is not the same pool you get from node:worker_threads. Confusing the two is the single most common misunderstanding I see from developers moving from a request-per-thread backend (Rails, classic PHP, blocking Java servlets) into Node. nodejs
The libuv event loop itself runs in phases, in this fixed order: timers, pending callbacks, idle handles, prepare handles, poll for I/O, check handles (this is where setImmediate fires), and close callbacks. Each pass is one "tick." The important operational fact hiding in that list: the loop cannot start the next phase until the current callback returns. There's no preemption. If your callback runs for 400ms, nothing else — no other request, no timer, no incoming socket — gets serviced for 400ms. nodejs
That single fact is the entire argument for and against Node as a backend runtime. It's why Node scales requests-per-second on I/O-bound workloads better than thread-per-request models, because idle threads waiting on a database response cost nothing. And it's the exact mechanism that turns one badly-written regex or one synchronous JSON.stringify on a large object into a full outage, not a slow request.
The mistake that ships to production: confusing "async" with "doesn't block"
Here's the version of this bug I've seen most often, in some form, in real Express codebases:
pbkdf2Sync is, by design, slow — that's the entire point of a password-hashing KDF. At 100,000 iterations it typically costs tens of milliseconds per call on commodity hardware. One request at a time, that's invisible. Ten concurrent login attempts, and every other request on that process — health checks, unrelated API calls, everything — queues behind them, because this line runs on the event loop thread, synchronously, and nothing else can run until it returns. Node's own guide names this explicitly and lists crypto.pbkdf2Sync, crypto.randomFillSync, the synchronous fs APIs, and zlib's sync methods as things you should never call on the event loop in a server process. nodejs
The fix is one word:
Now the actual computation happens on libuv's threadpool, not the event loop. Your event loop thread is free to keep serving other requests while up to four (by default) of these run concurrently in the background.
A second, nastier version of the same bug is a vulnerable regular expression, sometimes called ReDoS. Node's official guide gives this exact example:
That regex has a nested quantifier — (\/.+)+ — and on a mismatching input like a hundred slashes followed by a character the pattern can't match, V8's backtracking regex engine can take exponential time to conclude there's no match. Node itself will happily report a match quickly; it's the failure case that blows up, because the engine has to exhaust every possible path before giving up. This is a genuine, exploitable denial-of-service vector, not a theoretical one — a single malicious query string can take your entire process offline, not just the one request. nodejs
You should also know about JSON.stringify and JSON.parse at scale. Node's own benchmark: stringifying a nested object that produces a 50MB string takes roughly 0.7 seconds, and parsing it back takes roughly 1.3 seconds — all synchronous, all on the event loop. If your API accepts arbitrarily large JSON bodies from clients and parses them inline, you have the same shape of problem as the regex, just slower to notice. nodejs
Offloading costs you serialization, not CPU
Once you understand that the event loop is a single lane, the natural next question is: where do I put CPU-bound work? Node gives you node:worker_threads, added in v10.5.0 and stable since v12.11.0. The important line from the official docs, worth reading twice: "Workers (threads) are useful for performing CPU-intensive JavaScript operations. They do not help much with I/O-intensive work. The Node.js built-in asynchronous I/O operations are more efficient than Workers can be". Don't reach for a worker thread to do a database call — await already handles that better and cheaper. nodejs
Two things the docs are blunt about, which most tutorials skip. First: creating a Worker per request is expensive enough that the overhead can exceed the benefit — the pattern above is fine for a demo, but a real service needs a persistent worker pool, reused across requests. Second: workers don't share memory by default. Data passed via workerData or postMessage is cloned using the HTML structured clone algorithm, not passed by reference. Non-enumerable properties, getters, and prototypes don't survive the trip — a class instance posted to a worker arrives as a plain object. If you need actual shared memory, that's what SharedArrayBuffer is for, and it's the exception, not the default. nodejs
The unhandled rejection that used to be a warning now kills your process
This one bites people upgrading from an older Node version, or copying code from a five-year-old Stack Overflow answer. Node's default behavior for an unhandled promise rejection changed materially over time: it was silent originally, became a deprecation warning in v6.6.0, deprecated outright in v7.0.0, and today the default --unhandled-rejections mode is throw, meaning an unhandled rejection is raised as an uncaught exception and, absent a handler, crashes the process. infoq
The fix isn't exotic, but it has to be consistent, everywhere, including inside .then() chains that you think can't fail:
For process-level safety, add a last-resort handler, and understand what it can and can't do. Node's docs are explicit that uncaughtException "is a crude mechanism for exception handling intended to be used only as a last resort" and that it's "not safe to resume normal operation" after one fires — the only correct use is synchronous cleanup before exiting, not swallowing the error and continuing: infoq
Every framework built on top of Express-style middleware inherits this behavior. If your team's async route handlers don't wrap await in try/catch or delegate to a global error middleware, one thrown error from a database timeout can, on a bad day, take the process down with it — not gracefully return a 500.
Express is not the fast choice, and the public benchmark numbers on this are unreliable
Here's where I'll be blunt: if you're starting a new API in 2026 purely for throughput, Express is not the framework to reach for, and a lot of the marketing around "Express vs Fastify" numbers floating around right now is either outdated, run on mismatched hardware, or just wrong.
The most current tier-1 number I could find is from Fastify's own benchmark repository, last run 17 October 2025 on a 4-vCPU Linux box, Node v20.19.5, using autocannon -c 100 -d 40:
| Framework | Version | Requests/sec | Latency (ms) |
|---|---|---|---|
| node-http (no framework) | v20.19.5 | 47,642 | 20.5 |
| polka | 0.5.2 | 47,010 | 20.8 |
| fastify | 5.6.1 | 46,997 | 20.8 |
| koa | 3.0.2 | 34,709 | 28.3 |
| hapi | 21.4.3 | 31,916 | 30.8 |
| express | 5.1.0 | 10,302 | 96.5 |
| express-with-middlewares | 5.1.0 | 9,485 | 104.8 |
Fastify is roughly 4.6x Express on this run. Compare that to the number Fastify's own npm README quotes, from an older, differently-provisioned benchmark run (a dedicated Hetzner EX41S-SSD box, Fastify 4.0.0 vs Express 4.17.3): Fastify at 77,193 req/s against Express at 14,200 req/s, a roughly 5.4x gap. Both are legitimate, official, first-party numbers — they just disagree on the exact multiplier because the hardware, Node version, and framework major versions are all different. If you see a third-party blog quoting "Fastify is 2x faster" or "Fastify does 80,000 req/s," ask what hardware and what versions, because I found numbers across the low-quality end of the search results ranging from a 6% gap to a 9.7x gap, often for a "hello world" endpoint that tells you nothing about your actual handler doing a database round trip. Trust the framework's own repo, on the versions you're about to ship, over any aggregator. github
What the official numbers do establish reliably: the gap is real, it's structural (Express's middleware dispatch and lack of schema-compiled serialization cost real overhead per request), and it shrinks — sometimes to single-digit percentages — once your handler is dominated by a database call rather than framework overhead, because the framework's share of total request time drops. github
None of this means Express is dead. Express remains the most-used backend framework in the JavaScript ecosystem by a wide margin: State of JS 2025 puts it as the clear usage leader among back-end frameworks, ahead of Fastify and a fast-growing NestJS, and Stack Overflow's 2025 Developer Survey shows Node.js used by 48.7% of backend respondents and Express specifically by 19.9%. That dominance is inertia, not a verdict on speed — huge, mature codebases don't get rewritten for a framework swap, and for a small internal API doing a handful of requests per second, the performance difference is invisible. github
My actual position: for a new service, default to Fastify unless you have a specific reason not to — an existing Express-only middleware you can't replace, a team that's never touched Fastify's plugin/encapsulation model and has no time to learn it, or genuinely low traffic where the difference is academic. For an existing Express codebase doing under a few thousand requests per second, don't rewrite it for this reason alone — measure your actual handler under load first, because the framework is very rarely your bottleneck once a database is involved. github
You can delete the build step, and for most APIs you should
This is the part of the Node ecosystem that changed the most recently, and most articles on this topic are still describing 2023's Node. As of now, you do not need ts-node, tsx, Babel, or a tsc compile step to run TypeScript on the backend.
Node added type stripping behind an --experimental-strip-types flag in v22.6.0. It became enabled by default, no flag required, in v23.6.0 and v22.18.0. The experimental warning was removed in v24.3.0 and v22.18.0. And as of v25.2.0 and v24.12.0, type stripping is officially marked Stable — Node.js's second-highest stability grade. On any of those versions or later: byteiota
That runs, unmodified, no config file. What Node actually does is replace the type annotations with whitespace — it does not type-check, and it will error on any TypeScript syntax that requires generating new JavaScript rather than deleting old syntax: enum declarations, namespaces containing runtime code, constructor parameter properties, and import aliases will all throw ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. Decorators are unsupported for the same reason — they're still a TC39 Stage 3 proposal, not native syntax, and Node explicitly says it will not polyfill them. You also must write import type for type-only imports; a bare import { SomeType } from './module.ts' where SomeType is actually only a type will fail at runtime, because Node has no way to know it was type-only without the keyword. byteiota
The practical takeaway: type stripping is genuinely production-viable for straightforward Express/Fastify APIs written in plain, modern TypeScript. It is not a replacement for tsc in your CI — you still want tsc --noEmit as a separate check, because type stripping performs zero type checking; it will happily run code with a type error all the way to a runtime crash. Keep node file.ts for local dev speed and tsc --noEmit as a CI gate. That's a materially shorter toolchain than what backend TypeScript required even two years ago.
The built-in test runner and native fetch remove two more dependencies
Node ships a built-in test runner via node:test, invoked with node --test, that auto-discovers files matching *.test.* or anything under a test/ directory and runs each in its own process for isolation: nodejsdesignpatterns
It supports suites, hooks, mocking, filtering by name, watch mode, and code coverage out of the box. For a small-to-mid-size API, that's Jest, Mocha, and often nyc replaced with zero added dependencies — fewer packages to keep patched, fewer version-mismatch errors between a test framework and your Node version. nodejsdesignpatterns
Similarly, fetch(), Request, Response, Headers, and FormData are global in Node without any import, powered by a bundled version of Undici. Fetch shipped experimental in v18 (behind a flag in the earliest v18 releases), then was marked stable in v21. AbortController has been a stable global since v15.4.0, and structuredClone since v17.0.0. If you're targeting current LTS lines, calling another service from your backend needs nothing beyond: herodevs
That's the same fetch/AbortController API your frontend code already uses. This is the actual mechanical version of the "one language" hiring pitch — not that JS looks similar on both sides, but that the literal same standard-library API for making an HTTP call, aborting it, and cloning an object works unmodified on the client and the server.
The hiring argument, made properly
The pitch in the brief — "sharing one language across front and back speeds up learning and hiring" — is directionally true, but the naive version of it overstates the case, and I want to be specific about what the data actually supports rather than restate the marketing line.
JavaScript remains the most-used language among developers for the thirteenth consecutive year in Stack Overflow's 2025 survey, at 66% of respondents having used it in the past year, ahead of Python at 57.9%. On the backend specifically, Node.js sits at 48.7% usage among surveyed developers, with Express at 19.9% — so among people who write backend code, roughly half touch Node in a given year. That's a large, liquid labor pool: a hiring manager posting a Node.js/Express role is drawing from a bigger candidate set than one posting for, say, an Elixir or a niche framework role, purely on volume. hidekazu-konishi
What the "one language" argument actually buys you, concretely, in a small or mid-size team:
- A frontend engineer can read, and in many cases safely modify, a backend route handler without context-switching syntax, package manager, or type system. This is real and I've seen it hold up — the type annotations, the
async/awaitshape, and now thefetch/AbortControllerprimitives are identical on both sides. - Onboarding a junior full-stack hire is genuinely faster when they only have to internalize one language's idioms (closures, promises, module resolution) rather than JavaScript's plus a second language's entirely different concurrency and error-handling model.
- Shared tooling: one linter config family (ESLint), one formatter (Prettier), one package manager, one CI toolchain shape, for both halves of the stack.
What it does not buy you, and where I think the pitch oversells: it does not make a mediocre backend engineer into a good one. Understanding the event loop, worker pools, and unhandled-rejection semantics covered above is backend-specific knowledge that a strong React developer does not automatically possess just because the syntax is familiar. I've watched teams hire "full-stack JS" generalists expecting backend competence to transfer for free from frontend experience, and get burned by exactly the blocking-callback and ReDoS-shaped bugs in the sections above. The language is shared; the runtime model is not, and treating them as equivalent is where the hiring pitch breaks down in practice. Budget real onboarding time for the backend-specific mental model even for hires who are already strong in JavaScript.
Where Node loses: CPU-bound work, and what "it doesn't scale" actually means
The people who say "Node doesn't scale" are half right, and it's worth being precise about which half. Node scales I/O-bound concurrency exceptionally well — that's the entire architectural bet, and it's why a Node process handling thousands of concurrent database-bound requests can run comfortably on hardware that would need many more threads in a blocking model. Where Node genuinely struggles is sustained CPU-bound work on the request path: image resizing, PDF generation, large-scale data transformation, cryptographic signing at volume. Every millisecond spent computing on the event loop is a millisecond every other concurrent request waits, with no operating-system preemption to bail you out. nodejs
The honest answer isn't "don't use Node," it's "don't put CPU-bound work on the event loop, and know when even worker threads aren't enough." Worker threads help for genuinely parallelizable CPU work up to the number of physical cores you have available — beyond that, as Node's own docs note, extra workers just add scheduling overhead without extra throughput, because they're all fighting for the same finite cores. If your service's primary job is CPU-bound at scale — a video transcoding pipeline, a machine learning inference server, heavy in-process cryptography — Node is architecturally the wrong default, and reaching for Rust, Go, or a language with real OS-level thread parallelism for that specific service, behind an API your Node frontend calls, is the correct answer, not a failure of Node. nodejs
For the vast majority of full-stack roles — CRUD APIs, webhook handlers, BFF (backend-for-frontend) layers, auth flows, integration glue between a database and a frontend — the I/O-bound case dominates, and Node's model is the right fit, not a compromise.
The thing that will actually determine whether learning this pays off for you isn't the language-sharing pitch — it's whether you take the time to understand the event loop well enough to recognize a blocking call before it ships, not after it's paged you at 2am. That knowledge doesn't come from a tutorial that stops at "async/await lets you write asynchronous code that looks synchronous." It comes from reading Node's own guide on not blocking the event loop once, carefully, and then going back to look at your own route handlers with that lens. Do that, and the shared-language hiring argument becomes a genuine bonus on top of a runtime you actually understand, rather than a language you're fluent in running on a model you're guessing at.
