ASP.NET Core and Node will both serve a request that reads a database, calls two neighbors and returns JSON, and the argument between them is almost never about the thing that actually differs. This traces the one difference that does, where each runtime put its parallelism, through the two blocking failures, the type systems and the framework lifecycles. For the large middle it lands somewhere uncomfortable, which is that the deciding factor is who can debug it at three in the morning.
9 September 2025·7 min read·languagesarchitecture
A request arrives on a socket. Something has to read it, work out what it means, wait on a database that takes a few milliseconds longer than anyone would like, and write bytes back. Outside the JVM, ASP.NET Core and Node are the two answers most teams are choosing between, and I have sat through that argument more times than I can usefully count. It is rarely about the thing that differs.
Node runs your JavaScript on one thread. Exactly one. The event loop takes a callback off a queue, runs it to completion, takes the next, and while your code runs nothing else in that process advances. Network I/O does not need the thread, because the operating system tells the loop when a socket is ready, so a handler that spends its time waiting spends most of its life off the thread. That is a good fit for the shape of work a web service does.
.NET puts your code on a work-stealing thread pool sized against the machine it finds itself on, and async/await rewrites each method into a state machine that hands the thread back at every await point. At the socket the effect matches Node's: a request waiting on a database is not sitting on a thread. The difference is what the runtime does with the other cores, which is use them, unasked.
So here is the sentence the rest of this is about. These are not the fast one and the slow one, they are the concentrated one and the distributed one, and every consequence worth arguing over is downstream of that. The rest of this piece traces them, and tries to be honest about which are real and which people repeat because somebody told them in 2014.
A blocking call in Node stops the process, not the request. A tight loop, a synchronous file read, a parse of something enormous, a regular expression that backtracks: any of these hold the one thread, and the loop does not advance. Every other in-flight request stalls, including ones that had finished computing and only needed their bytes written. The health endpoint stops answering and the load balancer pulls the instance. One bad line takes the process.
There are four threads nobody mentions. Node is not purely single-threaded underneath, and the part people miss is which work escapes. libuv's documentation is plain that its pool runs all filesystem operations plus getaddrinfo and getnameinfo, and that it defaults to 4 threads. File reads and DNS lookups are not event-driven at all; they queue. A per-request read on slow storage saturates four threads while the event loop idles and the CPU graph looks healthy.
.NET does not stall, it strangles. Block a pool thread with .Result or .Wait() and you consume one thread rather than the process, so nothing stops. The pool notices and injects more. Microsoft's own starvation tutorial gives the shape: thread count jumps to two or three times the core count, then climbs by one or two per second, CPU well under full. Latency degrades over minutes instead of falling over.
TypeScript is an excellent type system, and in places it is more expressive than C#'s. Unions that discriminate on a literal, mapped and conditional types, a structural model that describes the shape a third-party JSON blob actually has rather than the shape you wish it had: a C# developer meeting those usually wants some of them. The difference is not how much the two can say. It is when anybody checks.
In C# the runtime knows. A wrong cast throws where it happens rather than three layers later. Generic type arguments survive into execution, which is why serializers, dependency injection containers and object-relational mappers read them and do the right thing without being told twice. The guarantee at your process boundary is enforced by the thing running your code, not by a step that ran on a build agent forty minutes ago.
The other side of that is why TypeScript exists at all. A type system that never has to run can be adopted one file at a time, can describe things no runtime could enforce, and can be wrong about a library without breaking it. C# bought enforcement and pays where enforcement is expensive: a compile step in every loop, and a language careful about every feature it adds because the runtime has to carry it forever.
ASP.NET Core is versioned with the platform. There is one framework, it ships inside the runtime, it carries the same major version, and it inherits the same published support window. .NET 8 arrived on 14 November 2023 as a long-term release with three years of patches, and .NET 9 followed a year later on the shorter track. You can put the end-of-support date on a slide before you write the first controller, and the person who has to argue for upgrade budget in year three has a document to point at.
Node's runtime lifecycle is just as disciplined. The churn accusation is usually aimed at the wrong layer. Node publishes a schedule and keeps it: even-numbered majors ship in April, become long-term in October, and are supported for thirty months. Node 22 landed on 24 April 2024, went long-term that October, and is dated to 30 April 2027; Node 18 ended on 30 April 2025, on the day the schedule had said for three years. The runtime is not where the surprises live.
The framework is where the lifecycle stops existing. Express published 5.0.0-alpha.1 on 7 November 2014 and 5.0.0 final on 10 September 2024, with 5.1.0 the following March. Ten years. Read it both ways, because both are true: almost nothing anyone wrote against Express 4 broke in that decade, a compatibility record most first-party frameworks cannot touch, and there was never a date by which anybody had to do anything, so the conversation about moving simply never happened.
I will not quote pool sizes, because every number available is a survey of people who answer surveys, and both pools will fill a role in most markets at a normal price. Shape is the interesting property, and the two differ because people arrive through different doors. Most JavaScript engineers came in through the browser, so a Node hire often brings deep instincts about latency and shallow ones about connection pools. The variance there is enormous, and a loop that does not probe for it hires the wrong half.
The C# pool came in through the enterprise and tends toward longer tenure inside one large codebase, which is exactly the experience a large long-lived codebase needs. The cost sits at the edges: fewer candidates arrive already fluent, the pool skews toward one shape of organization, and moving a JavaScript engineer onto C# is a ramp of months rather than a weekend. Neither is a quality judgment. They are different distributions and you hire from one of them.
Memory favors Node, and the default is worth checking. A Node process starts small and stays small unless you make it otherwise, which is convenient. .NET reads as heavier largely because its collector sizes heaps against the memory it can see, so a container with a generous limit shows a large resident set that is mostly unpressured headroom. Server against workstation collection mode is the lever that moves it, and it is a setting many teams have never once looked at.
Cold start is the difference that is still real. Node serves almost immediately because there is nothing to warm up. .NET compiles as it goes, so the first requests through a path are slower than the thousandth, and the answers - a precompiled ready-to-run image, or an ahead-of-time native binary - trade that for build complexity and, in the second case, real limits on reflection. If you scale to zero this decides things. If your instances stay up, it is a graph nobody looks at twice.
There are cases where the technical answer is clean. Node is right for a service that is mostly waiting: an aggregator, a gateway, a webhook receiver, anything whose job is to hold many connections and do very little per connection. It is right when the team is genuinely fluent in TypeScript on both sides and the same people work on both. And it is right when you want the option to change the framework under you without changing the runtime, which is a freedom .NET does not sell.
.NET is right when there is arithmetic on the request path, because the pool spreads it and you never design around that. It is right for a codebase that will be alive in ten years and touched by people who did not write it, where a type system the runtime enforces pays a dividend on every refactor forever. And it is right for a shop already inside it, where migration cost swamps every framework preference anyone holds.
Then there is the large middle, which is most services, and where both of those paragraphs stop applying. Something that reads a database, calls two neighbors, applies rules and returns JSON will be fine on either, in the sense that the choice never surfaces in the latency graph or the bill. Everything above is still true about that service. None of it decides anything about it.
So here is the argument I find hardest to beat, and it runs against most of this essay. For that middle case the deciding factor should be which runtime your people can operate at three in the morning - operate rather than write: read a flame graph, take a dump, reason about the pool, know what the framework does before their code runs. A worse platform your engineers have debugged under pressure beats a better one they have read about. I have spent the piece on what that outweighs.
Underneath, both are the same shape: something notices a socket is ready, and something runs your code. One concentrated that into a single thread and made every blocking call a bug you must not write. The other spread it over a pool and made every blocked thread a slow leak nobody sees. Neither escaped the problem, and the argument in the room is really a preference about which failure you would rather be paged for.