Latency distribution curve highlighting the p99 tail, with Go, Rust and Node.js labels

Go vs Rust vs Node.js for APIs Above 10k req/sec

Hamza Anis
Hamza AnisAuthor
·12 min read

For a high-throughput API in the 10k–100k requests-per-second range, Go is the pragmatic default. It uses every core without ceremony, holds tail latency well under load, and is quick to write and hire for. Rust is the right call when you need predictable latency with no garbage collector, tight memory control, or the most throughput per core, and your team can pay for the learning curve. Node.js is fine for I/O-bound product APIs and fast iteration, but it runs out of room first on CPU-heavy, highly concurrent paths. The honest tiebreaker is your own workload, measured at the p99, and this post ends with a harness to do exactly that.

We build Golang backends that sustain 10k–100k req/sec, so we have a bias. We also turn down Go when it's the wrong tool. Here's how we actually decide.

Why hello-world benchmarks mislead

Most "Go vs Rust vs Node" posts benchmark an endpoint that returns a constant string. Real APIs don't do that. A typical request:

  1. parses and validates a JSON body,

  2. checks auth (a token verification or a cache lookup),

  3. makes one to five calls to Postgres, Redis, or another service,

  4. runs some business logic, and

  5. serializes a response.

On that workload, the runtime's raw HTTP speed is a small slice of the total. What separates the three languages is how they behave when thousands of those requests overlap: how they schedule concurrent work, how much memory each in-flight request holds, and what garbage collection does to the slowest 1% of requests.

Framework leaderboards like TechEmpower are useful for seeing the ceiling of a stack. They don't predict your p99.

The metrics that actually decide it

When we size a backend, these are the numbers we care about, in order:

  • p99 latency at target load.

    Averages hide the requests that time out. Capacity is set by the tail.

  • Throughput per core at that p99.

    This is what drives your instance count, and your cloud bill.

  • Memory per concurrent request.

    It decides how many connections an instance holds before it swaps or gets OOM-killed.

  • Behaviour under overload.

    Does latency degrade gracefully, or does the process fall over?

  • Cold start and deploy time

    , if you autoscale aggressively or run serverless.

How the three runtimes handle concurrency

Node.js runs your JavaScript on a single thread per process with an event loop; I/O is asynchronous and efficient. The catch is that any CPU work (JSON parsing of large bodies, crypto, templating, heavy validation) blocks every other request on that process. You scale by running one process per core (cluster mode or more containers) and moving CPU-heavy work to worker threads. It works, but the tail latency on mixed workloads is where it hurts.

Go multiplexes goroutines onto all available cores. A goroutine per request is cheap, blocking code reads naturally, and the scheduler keeps the cores busy. CPU-heavy work in one request doesn't stall the others. The cost is a garbage collector, which on allocation-heavy services shows up as CPU overhead and, historically, as tail-latency noise.

Rust (typically with Tokio and Axum or Actix) gives you async I/O across cores with no garbage collector at all. Memory is freed deterministically, so there's no GC in the tail. The cost is developer time: ownership, lifetimes, and async Rust have a real learning curve, and compile times are longer.

What changed in Go 1.26

Go 1.26 (February 2026) made the Green Tea garbage collector the default. The Go team reports a 10–40% reduction in GC overhead in real programs that lean heavily on the GC, with an additional gain on newer x86 CPUs from vectorized scanning. For typical JSON-heavy API services, which allocate lots of small, short-lived objects, that is exactly the pattern Green Tea targets.

In practice this narrows the gap to Rust for allocation-heavy services without code changes: upgrade the toolchain, rebuild, and re-run your load tests. You still control the rest yourself: reuse buffers on hot paths, avoid needless []byte↔string conversions, and set GOMEMLIMIT so the collector works with your container's memory limit instead of against it.

Team and hiring cost

Throughput per core is only half the bill. The other half is people:

  • Go:

    small language, fast onboarding, readable code across the team, and a large pool of backend engineers. New hires ship in weeks.

  • Rust:

    smaller hiring pool, longer ramp-up, higher seniority requirements. Worth it when performance or safety is the product.

  • Node.js:

    largest pool, shared language with the frontend, fastest early iteration. The hidden cost appears later, in performance work on hot paths.

The decision table

If your situation is…

We'd pick

Product API, mostly I/O, moderate traffic, full-stack JS team

Node.js

, and move hot paths out later if needed

High-concurrency API, 10k–100k req/sec, business logic plus DB calls

Go

Latency-critical path (trading, real-time bidding, packet processing), strict p99/p999 targets

Rust

Memory-constrained edge or embedded components

Rust

Existing Node.js monolith with one or two hot endpoints

Keep Node.js, extract the hot paths to Go services

Streaming/event workers doing moderate CPU work per message

Go

That fifth row is the most common situation we see, and it's usually cheaper than any rewrite. We cover the "extract, don't rewrite" approach in modular monolith vs microservices, and choosing the event backbone for those workers in Kafka vs NATS for Go pipelines.

Scaling past your current stack? Our free Backend Scaling Checklist covers the 12 checks we run before a system hits 10k req/sec: load testing, finding the real bottleneck, caching, database tuning, timeouts, backpressure, and more. Run them before anyone talks about rewriting.

Benchmark it yourself: a reproducible harness

Don't trust our table, or anyone's. Run the same realistic endpoint in each candidate and load test it with an open-model generator, so the load doesn't politely slow down when the server does.

Here's a representative Go endpoint: JSON in, one Postgres query, JSON out.

Go
package main

import (
	"context"
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
)

type quoteRequest struct {
	SKU string `json:"sku"`
	Qty int    `json:"qty"`
}

func quoteHandler(db *pgxpool.Pool) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req quoteRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.SKU == "" || req.Qty <= 0 {
			http.Error(w, "bad request", http.StatusBadRequest)
			return
		}

		ctx, cancel := context.WithTimeout(r.Context(), 50*time.Millisecond)
		defer cancel()

		var priceCents int64
		err := db.QueryRow(ctx, `SELECT price_cents FROM prices WHERE sku = $1`, req.SKU).Scan(&priceCents)
		switch {
		case errors.Is(err, pgx.ErrNoRows):
			http.NotFound(w, r)
			return
		case err != nil:
			http.Error(w, "unavailable", http.StatusServiceUnavailable)
			return
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]int64{"total_cents": priceCents * int64(req.Qty)})
	}
}

func main() {
	db, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	mux := http.NewServeMux()
	mux.HandleFunc("POST /orders/quote", quoteHandler(db))

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 2 * time.Second,
		WriteTimeout:      5 * time.Second,
		IdleTimeout:       60 * time.Second,
	}
	log.Fatal(srv.ListenAndServe())
}

Write the same endpoint in Node.js (e.g. Fastify with pg) and Rust (Axum with sqlx), point them at the same database, and drive them with k6 using a constant-arrival-rate scenario:

JavaScript
import http from 'k6/http';
import { check } from 'k6';

export const options = {
  scenarios: {
    ramp: {
      executor: 'ramping-arrival-rate', // open model: load doesn't back off when the server slows
      startRate: 1000,
      timeUnit: '1s',
      preAllocatedVUs: 500,
      maxVUs: 5000,
      stages: [
        { target: 10000, duration: '2m' }, // ramp to 10k req/s
        { target: 10000, duration: '5m' }, // hold
        { target: 30000, duration: '2m' }, // push past target to find the knee
      ],
    },
  },
  thresholds: {
    http_req_duration: ['p(99)<100'], // your SLO, in ms
    http_req_failed: ['rate<0.001'],
  },
};

export default function () {
  const res = http.post(
    `${__ENV.TARGET}/orders/quote`,
    JSON.stringify({ sku: 'SKU-1', qty: 3 }),
    { headers: { 'Content-Type': 'application/json' } },
  );
  check(res, { 'status is 200': (r) => r.status === 200 });
}

Rules that keep the comparison honest:

  • Same instance type, same CPU and memory limits

    , same database, same dataset size.

  • Warm up

    before measuring, and run the load generator on separate machines so it isn't the bottleneck.

  • Record p50, p99, and error rate

    at each stage, plus CPU and memory per instance.

  • Find the knee:

    the request rate where p99 starts climbing steeply. Your capacity plan lives just below it.

  • Tune each stack fairly:

    connection pool sizes, Node cluster mode,

    GOMAXPROCS

    /

    GOMEMLIMIT

    in containers, Tokio worker threads.

Divide your target traffic by the sustainable rate per instance and you have an instance count, and a cloud bill, for each option. That number, plus the team-cost column above, is the decision.

When we pick each

We default to Go for new high-throughput services because it hits the throughput target with the least engineering time, and because the result is readable by the next team. We reach for Rust on the rare paths where a garbage collector's tail is unacceptable. And we often leave Node.js exactly where it is, extracting only the endpoints that are costing you instances.

Here's an illustrative example (not a specific client): a Node.js API peaks at 20,000 requests per second, and one hot endpoint does heavy JSON transformation and fan-out. If each Node.js instance sustains about 1,500 requests per second within its p99 latency target, that endpoint needs 14 instances. Rewritten in Go, the same endpoint might sustain around 5,000 requests per second per instance, bringing it down to 4 or 5. Your numbers will differ, which is why the load-test harness above matters more than any published benchmark.

FAQ

Is Go faster than Node.js for APIs?

For CPU-bound and highly concurrent API work, usually yes: Go runs request handling across all cores with goroutines, while a single Node.js process runs JavaScript on one thread. For I/O-bound APIs with light CPU work, Node.js can be perfectly adequate. Measure p99 latency on your real workload before deciding.

Should we use Rust instead of Go for our backend?

Choose Rust when you need predictable latency without a garbage collector, tight memory control, or maximum throughput per core, and your team can absorb its learning curve. For most business APIs, Go reaches the required throughput with faster delivery and easier hiring.

What does Go 1.26's Green Tea garbage collector change?

It is now the default collector and, according to the Go team, reduces garbage-collection overhead by roughly 10 to 40 percent in programs that make heavy use of the GC. Allocation-heavy services get lower CPU cost and better tail latency without code changes.

Are public benchmarks like TechEmpower useful?

They're useful for seeing the ceiling of a framework, not for predicting your system. Real APIs spend most of their time in serialization, database calls, and business logic. Benchmark your own endpoints with production-like data and look at p99 latency, not averages.

Can we mix languages?

Yes, and it's common: keep Node.js or your existing stack for product APIs and move the hot paths to Go (or Rust) as separate services. That's usually cheaper than rewriting everything.

Pick the runtime with numbers, not opinions

We design and load-test high-performance Golang backends that hold 10k–100k req/sec, and we'll tell you straight when Go isn't the answer. Book a technical discovery call and bring your slowest endpoint. We'll show you where the time goes.

Hamza Anis

Written by Hamza Anis

CEO & Senior Backend Engineer at CapregSoft. Building high-performance backends and AI automation systems for funded startups and mid-market teams.

Free resource

The Backend Scaling Checklist

The 12 checks we run before a system hits 10k req/sec — what to fix before it breaks in production. Free, no fluff.

Currently booking·2 discovery slots open this month
Book a call

Is your stack the bottleneck? Let's find out.

30-min call. We'll tell you straight if we can help.

No obligation. If we're not the right fit, we'll tell you straight — and point you somewhere better.