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.
Most "Go vs Rust vs Node" posts benchmark an endpoint that returns a constant string. Real APIs don't do that. A typical request:
parses and validates a JSON body,
checks auth (a token verification or a cache lookup),
makes one to five calls to Postgres, Redis, or another service,
runs some business logic, and
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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
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.
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.
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.
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.
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.

CEO & Senior Backend Engineer at CapregSoft. Building high-performance backends and AI automation systems for funded startups and mid-market teams.
The 12 checks we run before a system hits 10k req/sec — what to fix before it breaks in production. Free, no fluff.