EngineeringMarch 27, 20267 min read

Why Rust over Node.js for production backend services

Node.js is one of the most productive backend runtimes ever built. You can go from zero to a working API in an afternoon. But productivity at development time and reliability at runtime are different problems, and the gap between them widens under production load. Here is where that gap shows up, what causes it, and when it matters enough to choose Rust instead.

The garbage collection problem

Node.js runs on V8, which uses a generational garbage collector. For most workloads, this is invisible. V8's minor GC (Scavenge) pauses the event loop for 1-3ms to clean up short-lived objects in the young generation. You don't notice it at 100 requests per second.

At 10,000 requests per second, the picture changes. The old generation fills faster because more objects survive the nursery. V8 triggers major GC cycles (Mark-Sweep-Compact) that pause the event loop for 10-50ms. Under sustained high load, these pauses can spike to 100ms or more. During that pause, every in-flight request stalls. No I/O callbacks fire. No new connections are accepted. Your p99 latency jumps from 15ms to 150ms and you can't optimise your way out of it because the pause is happening inside the runtime, not in your code.

You can tune V8's heap limits with --max-old-space-size and --max-semi-space-size, but you're trading between more frequent short pauses or less frequent long pauses. The fundamental problem remains: the runtime stops your code to clean up memory.

Rust has no garbage collector. Memory is allocated on the stack when possible and freed deterministically when values go out of scope. Heap allocations are managed through ownership - when the owner is dropped, the memory is freed immediately, no pause required. There is no stop-the-world event. Your p99 latency at 10,000 rps looks the same as your p99 at 100 rps.

Memory safety without the cost

Node.js inherits JavaScript's loose type system. A variable can be undefined when you expected a string. A function can receive an object missing a required field. TypeScript catches some of these at build time, but its type system is structural and opt-in - it doesn't prevent runtime type errors in dependencies, edge cases with any casts, or mismatches between your types and the actual data coming from external APIs.

Rust's type system operates at the compiler level and is non-negotiable. If your function signature says it returns a Result<User, DbError>, you must handle both the success and error case. There is no null. Option<T> forces you to handle the absence of a value explicitly. Pattern matching on enums must be exhaustive - add a new variant and the compiler flags every match statement that doesn't handle it.

The ownership system prevents data races at compile time. You cannot have two mutable references to the same data. You cannot use a value after it's been moved. These aren't runtime checks that add overhead - they're compile-time guarantees that produce zero-cost abstractions in the final binary. The result is that entire categories of production bugs - null pointer dereferences, use-after-free, data races, buffer overflows - literally cannot compile.

Async runtimes: Tokio vs the event loop

Node.js runs on a single-threaded event loop backed by libuv. All JavaScript executes on one thread. I/O operations are offloaded to a thread pool (default size: 4), and callbacks fire on the main thread when I/O completes. This model is excellent for I/O-bound workloads because it avoids thread context switching overhead. But it means any CPU-intensive work blocks the entire event loop. A 50ms JSON parse or bcrypt hash blocks every other request in the process.

The workaround is worker_threads, but each worker is a separate V8 isolate with its own heap. Communication between workers requires serialisation. You're essentially running multiple processes with the overhead of message passing, not true shared-memory concurrency.

Tokio, the standard async runtime in Rust, is multi-threaded by default. It runs a work-stealing scheduler across all available CPU cores. Async tasks are lightweight (a few hundred bytes of state, compared to 1MB+ per OS thread) and can yield at any .await point. A CPU-intensive task on one core doesn't block tasks on other cores. You get true parallelism without the overhead of process-level isolation.

For a service handling mixed workloads - some requests are pure I/O, some involve JSON serialisation of large payloads, some run business logic with moderate CPU cost - Tokio's model handles the mix naturally. Node.js requires careful architecture to avoid blocking, and even then, a single slow synchronous operation can cascade.

Real throughput numbers

Benchmarks vary by workload, but the patterns are consistent. For a typical JSON API that reads from a database, serialises a response, and returns it:

  • Express.js (Node.js): ~8,000-12,000 requests/second on a single core. Latency p99 around 15-25ms at moderate load, spiking to 80-150ms under sustained high concurrency due to GC and event loop saturation.
  • Fastify (Node.js): ~25,000-35,000 requests/second on a single core. Faster JSON serialisation and lower overhead than Express. P99 latency holds better but still subject to GC pauses at high load, typically 20-60ms spikes.
  • Axum (Rust/Tokio): ~80,000-120,000 requests/second on a single core. P99 latency stays under 5ms even under sustained load because there are no GC pauses. Multi-core throughput scales linearly - 4 cores gives you roughly 4x throughput with no architectural changes.

The throughput gap is significant, but the latency consistency is where Rust changes the game. A service running at 50,000 rps with a stable 3ms p99 is fundamentally different from one running at 30,000 rps with occasional 100ms spikes. Those spikes cascade through service meshes. A 100ms pause in one upstream service means thousands of downstream requests queueing, which triggers timeouts, which triggers retries, which amplifies the original problem.

Memory consumption tells a similar story. A Node.js process serving a moderately complex API typically uses 80-200MB of RSS memory. The equivalent Rust service uses 10-30MB. In containerised environments where you're packing services onto shared nodes, that 5-10x reduction in memory footprint means more services per node or smaller (cheaper) nodes.

When Node.js is still the right choice

Rust is not always the answer. Node.js wins in specific, common scenarios:

  • Rapid prototyping and MVPs. If you're validating a product idea and expect to iterate heavily on the API surface, Node.js lets you ship in days. Rust's compiler is strict and the learning curve is steep. The time from "idea" to "deployed API" is 2-4x longer in Rust.
  • I/O-bound services with low CPU requirements. A service that proxies requests, reads from a cache, and returns results spends almost all its time waiting on I/O. Node.js handles this efficiently. The GC pressure is low because you're not creating complex object graphs, and the event loop is rarely blocked.
  • Teams without Rust experience. A team writing idiomatic Node.js will produce a more reliable system than a team writing beginner Rust. The borrow checker catches memory bugs, but it doesn't catch architectural mistakes. A poorly designed Rust service is still poorly designed.
  • BFF (Backend-for-Frontend) layers. Services that aggregate data from other APIs, apply light transformations, and return it to a frontend. These are thin, I/O-heavy, and change frequently. The development speed of Node.js matters more than the runtime performance of Rust here.

When Rust wins

The decision to use Rust over Node.js should be driven by specific technical requirements, not preference:

  • CPU-intensive request handling. If your service does meaningful computation per request - data transformation, compression, encryption, ML inference, complex business logic with large data structures - Rust's performance is not marginally better, it's categorically different.
  • Latency-critical paths. Payment processing, real-time bidding, trading systems, live data feeds. Any service where a 100ms tail latency spike has business consequences. The absence of GC pauses is not a nice-to-have, it's a requirement.
  • High-concurrency services. Services handling 50,000+ concurrent connections. Tokio's multi-threaded runtime with lightweight tasks scales to millions of concurrent connections on a single machine. Node.js clusters can scale horizontally, but the per-process overhead and GC pressure at that scale become the dominant cost.
  • Long-lived services with strict memory budgets. Node.js processes tend to grow their heap over time as V8's GC becomes less aggressive. Rust services maintain a flat, predictable memory profile indefinitely because there is no GC heuristic deciding when to free memory - it's deterministic.

The tradeoff is real

Rust is harder to learn, slower to write, and has a smaller ecosystem for web services than Node.js. The compile times are longer. The hiring pool is smaller. These are real costs that affect delivery speed, team scaling, and maintenance burden.

But for services that run in production for years, handle real traffic with real SLAs, and sit on critical paths - the total cost of ownership shifts. Fewer production incidents. Lower infrastructure costs. Predictable performance under load. No 3am pages because a GC pause triggered a cascade failure.

The right question is not "which language is better." It's "what does this specific service need to do, at what scale, with what reliability guarantees, for how long." Answer that honestly and the choice usually makes itself.

Kaev builds production backend services in Rust and Node.js - chosen by the problem, not the hype cycle. If you're making this decision for a system that matters, let's talk through it.

Back to blog