This article is published in English.
Node.js vs Go for a JSON API: Same Throughput, 2.6x Less Memory
A side-by-side load test of identical Node.js and Go HTTP services shows where a rewrite pays off (resident memory) and where it does not (throughput and latency).
"Rewrite it in Go" is a pitch most Node.js teams hear sooner or later, and it usually arrives with claims about speed, event-loop saturation and lower cloud bills but no measurements. The way to settle it is to build the same small service in both languages, load it identically and see which differences survive repeated runs. This article walks through such an experiment, what it actually showed, which of its numbers were artifacts, and how to turn the result into a sensible migration decision for your own services.
The timing of the question is not accidental. The TypeScript team's move to a Go-based native compiler has made "port it to Go" feel like a default answer for performance problems (see what the TypeScript 7 Go rewrite means in practice). A compiler, however, is a CPU-bound batch program; a web API has a very different profile, which is exactly why it needs its own measurements.
A deliberately boring service
The test subject is a minimal JSON API over an in-memory user store seeded with 10,000 records. It exposes two routes:
GET /users/:idlooks up a user and returns it, or a 404 JSON error.POST /usersparses a JSON body, validates it and inserts a new user.
There is no database and no framework. Both choices are intentional: a database round trip would dominate the timings and hide any runtime difference, and a framework would add its own overhead that has nothing to do with the language. Routes, validation rules and seed data are identical in both implementations.
The environment and its built-in asymmetry
Everything ran on a single 16-core Apple Silicon laptop with macOS 26.6, Node v22.15.0 and Go 1.24.4. Node ran as one process, without the cluster module or worker_threads, so request handling used a single core. Go's net/http ran with the default GOMAXPROCS, which lets the scheduler spread goroutines across all cores.
Keep that asymmetry in mind for the rest of the article. It is the most important caveat in the whole comparison, and the load generator was competing with both servers for the same 16 cores.
The two implementations
The Node version uses nothing but the built-in http module. Notice that routing is a manual check on req.method and a startsWith on the URL, the store is a plain Map, and every response is serialized with JSON.stringify and ended explicitly. The POST branch is summarized in a comment; it applies the same checks as the Go code further down.
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url.startsWith("/users/")) {
const id = req.url.split("/")[2];
const user = users.get(id);
if (!user) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(user));
return;
}
// POST /users: parse body, validate name + email, insert. Same checks as Go below.
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
The Go version registers a handler on a ServeMux and trims the path prefix to get the id. The store is a map protected by a mutex, which is required because Go handles requests concurrently on multiple goroutines, whereas Node's single-threaded event loop never touches the Map from two places at once. Responses are written with json.NewEncoder, which streams straight into the ResponseWriter.
mux.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/users/")
user, ok := store.get(id)
w.Header().Set("Content-Type", "application/json")
if !ok {
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(user)
})
Validation on the POST route is the same in both languages: name must be a non-empty string and email must contain an @. Here is the Go function; the Node equivalent uses typeof and String.prototype.includes for the same two checks, so neither side gets a cheaper code path.
func validate(u User) string {
if u.Name == "" {
return "name required"
}
if !strings.Contains(u.Email, "@") {
return "invalid email"
}
return ""
}
How the load was applied
Load came from autocannon, in 15-second runs at two concurrency levels: 50 connections for a moderate load and 300 connections to approximate a busy endpoint.
autocannon -c 50 -d 15 http://localhost:PORT/users/500
autocannon -c 300 -d 15 http://localhost:PORT/users/500
The POST route was tested the same way with a JSON body, because decoding and validating input is closer to real request work than a single map lookup.
Memory was measured separately. Each server's resident set size (ps -o rss) was sampled at idle and again immediately after a 300-connection run, and each measurement started from a freshly launched process so leftovers from a previous run could not skew the figure. Crucially, the memory sampler was kept off the machine during the latency runs; as the next sections show, that detail changed the results.
Before looking at the outcome, take the scope seriously: this is one laptop, not an isolated benchmarking rig, and the load generator shares CPU with the servers. The numbers are directional for this workload, not a general verdict on Node versus Go. When you run a comparison like this, save the server code, the benchmark script and the raw output (here, a benchmark.json file) next to your conclusions, and record which numbers reproduced and which you discarded.
Throughput: no winner
In no run did Go open up a meaningful lead in request rate. With 50 connections on the GET route, it was Node that came out ahead, by roughly 5,000 requests per second. On the POST route and at 300 connections, the two stayed within a few hundred requests per second of each other. The familiar claim that Node "chokes under load" did not reproduce.
The setup explains part of this. With the load generator and both servers sharing 16 cores over loopback, no process was starved for CPU, which flattens the runtime differences you might see on a saturated production host. On a small dedicated instance the gap would probably grow. That is a genuine limit of laptop benchmarking and should be stated rather than hidden.
Latency: also a tie, once the noise was removed
At 300 connections, the median, p99 and even maximum latencies were within a couple of milliseconds of each other.
An earlier run had shown a 230 ms maximum for Node, a number dramatic enough to build an entire conclusion around. Re-running on a quiet machine, without the memory sampler competing for CPU, made the spike disappear. It came from the measurement tooling, not from a Node tail-latency problem.
This is a lesson that applies to any benchmark on a shared machine. A single worst-case figure is the most fragile number you will collect, because one scheduler hiccup or background process produces it. Before you trust a scary max, re-run on an idle machine, look at p99 alongside it, and check whether it reproduces.
Memory: the one difference that held up
Resident memory was the only gap that was both large and reproducible:
- Idle: Go at 13 MB, Node at 49 MB.
- Right after a sustained 300-connection load: Go at 32 MB, Node at 84 MB.
The measurement was repeated three times, each from a fresh process, with identical results. Under load that is roughly 2.6 times more memory in Node for functionally identical work.
The explanation is mostly structural. A Node process carries the V8 engine, its JIT compiler and a garbage-collected heap sized for a dynamic language, while a Go binary is compiled ahead of time with a leaner runtime. If you pay for container memory limits, the difference multiplies across every replica: an endpoint deployed to many pods pays the Node baseline in every single one of them.
What this experiment does not show
It is tempting to read this as "Go beats Node", but the data does not support that. Throughput and latency tied, and Node won the low-concurrency GET. If your constraint is requests per second or tail latency on a host with spare cores, this benchmark suggests a rewrite buys you almost nothing.
It also says nothing about database-bound work. Most production services spend the bulk of their latency waiting on queries rather than serializing JSON, and that time is the same in either language. If your service is I/O-bound on Postgres, these numbers are largely irrelevant to you.
Finally, the core asymmetry remains: Go used every core by default, and single-process Node used one. Some of what looks like a Go advantage is really "Go parallelizes automatically". The fair first step is Node's own cluster module with one worker per core, which gives Node the same hardware Go received for free. That was not tested here, and it is far less invasive than introducing a second language. Keep in mind that each clustered worker is a separate process with its own heap, so clustering can improve CPU utilization while making the total memory footprint larger, not smaller.
Turning the numbers into a decision
Given these results, a full rewrite does not clear the bar. A more targeted move does: port only the memory-hungry endpoint that runs on many replicas, where halving resident memory becomes a visible line item, and leave everything else in Node.
That restraint matters because moving code is never free. A second language brings another toolchain, another deployment pipeline, additional runbooks for on-call engineers and a split in team expertise. Those costs are worth paying where memory is the binding constraint and not worth paying where it is not.
When the next Node-to-Go proposal arrives, you can evaluate it the same way:
- Build both versions of the specific service or endpoint in question.
- Load them at the concurrency your real traffic reaches, including the write path.
- Give Node a fair chance with clustering before crediting Go with the difference.
- Measure memory separately from latency so the sampler does not distort the timings.
- Re-run every surprising number on a quiet machine before acting on it.
Key takeaways
- For a simple in-memory JSON API, Node and Go delivered effectively the same throughput and latency on this hardware.
- Go's clearest, repeatable advantage was about 2.6x lower resident memory under load, which matters most for widely replicated services.
- Default multi-core scheduling in Go versus single-process Node is a confounder; try clustering before rewriting.
- Benchmark artifacts, especially lone maximum-latency spikes, are common on shared machines; reproduce before you conclude.
- Migrate selectively, where the measured gain is real and outweighs the cost of a second language.