Go vs Node.js Load-Testing Benchmark
A controlled k6 throughput & latency study
- 712,788
- Requests
- 16
- Runs
- 0.00%
- HTTP failures
Overview
A controlled benchmark comparing a Go and a Node.js backend behind identical endpoints, database, and cache. The goal is not to crown a winner but to see how the performance gap shifts as load increases fivefold.
Both stacks are driven at two concurrency levels - 20 and 100 virtual users - against four endpoints chosen to isolate CPU, database, and cache behaviour. Across every endpoint and both load levels, Go leads on throughput by 1.19× to 1.65×, and the ordering never reverses.
Objective
Measure throughput and latency of equivalent Go and Node.js services under identical conditions, and observe how the gap changes when load rises from 20 to 100 virtual users.
Benchmark design
- 4 endpoints × 2 stacks × 2 load levels = 16 runs.
- A single, shared PostgreSQL 16 (10,000 rows) and Redis 7 - one instance used by both stacks, so neither gets a more favourable environment.
- Identical SQL (character for character), LIMIT 100, DB pool 25, Redis pool 25, and the same cache key & TTL on both sides.
- The same k6 script runs every combination; the load level is set through an environment variable, never by editing the file.
- constant-vus executor, 30 seconds per run, with thresholds p95 < 500 ms, http_req_failed < 1%, and checks > 99%.
Testing scope
Endpoints under test
- /hello - pure CPU, no I/O (HTTP-handling baseline)
- /products - light database query (LIMIT 100)
- /products-where-like - LIKE with a sequential scan (non-indexable)
- /products-with-cache - Redis cache-aside
Load levels
- 20 VU - workload character still dominates the gap
- 100 VU - endpoints converge to 1.19×–1.29× as shared resources saturate
Test strategy
- constant-vus executor, 30-second runs at 20 and 100 VU.
- Thresholds enforced by k6: p95 < 500 ms, http_req_failed < 1%, checks > 99%.
- Payload equivalence verified in absolute bytes so a smaller response cannot fake a speed win.
- k6 checks validate the body (count === 100) and the cache X-Cache: HIT header, not just status 200.
- The Redis key is flushed before each cache run so a stale TTL cannot contaminate results.
Measurement integrity
At 100 VU, Node unexpectedly recorded a lower p95 latency than Go on both database endpoints. Before treating that as a result, I checked where the time was spent: on /products the off-server portion of the request (p95 total minus server-side waiting) was ~34 ms for Go against ~1.27 ms for Node - nearly 27×. A 27% difference in transferred data (258 MB vs 203 MB) cannot explain a 27× difference in time, which points to the load generator rather than the application. Because k6 and both apps shared a single 8 GB laptop, that finding was withheld until it can be re-run with the load generator on a separate machine.
Architecture
- k6 (container)constant-vus load
- go-api / node-apiidentical endpoints
- PostgreSQL 16shared, 10,000 rows
- Redis 7shared cache-aside
Implementation
export const options = {
scenarios: { load: { executor: 'constant-vus', vus: 100, duration: '30s' } },
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.01'],
checks: ['rate>0.99'],
},
};
export default function () {
const res = http.get(`${BASE_URL}/products`);
check(res, {
'status 200': (r) => r.status === 200,
'got 100 products': (r) => r.json().count === 100,
});
}docker run --rm -i --network bench \
-v "$PWD/k6:/scripts" grafana/k6 run \
-e BASE_URL=http://go-api:8080 -e STACK=go -e VUS=100 \
/scripts/scenarios/products.jspostgres:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U benchuser -d benchdb"]
interval: 5s
timeout: 3s
retries: 10
go-api:
build: ./go-api
depends_on:
postgres: { condition: service_healthy }docker exec bench-redis redis-cli DEL products:top100Results - throughput
20 VU
/hello
/products
/products-where-like
/products-with-cache
100 VU
/hello
/products
/products-where-like
/products-with-cache
Requests per second per endpoint, Go vs Node.js. Go leads on throughput at every endpoint and both load levels. Source: k6/results/*.json.
Results
712,788
Requests
across 16 runs
0.00%
HTTP failures
100%
Functional checks passed
1.19–1.65×
Go throughput lead
413.36 ms
Highest p95
below the 500 ms threshold