All work
Personal project

Go vs Node.js Load-Testing Benchmark

A controlled k6 throughput & latency study

k6Docker ComposeGo 1.24 (net/http)Node 20 (Express 4)PostgreSQL 16Redis 7
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

  1. k6 (container)constant-vus load
  2. go-api / node-apiidentical endpoints
  3. PostgreSQL 16shared, 10,000 rows
  4. Redis 7shared cache-aside

Implementation

k6 checks assert the response body and cache state, not just the HTTP status.
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,
  });
}
One script covers every combination; the stack and load level come from env vars.
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.js
Compose healthchecks keep apps from starting before the database is ready.
postgres:
  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 }
The cache key is deleted before each cache run so a leftover TTL cannot skew it.
docker exec bench-redis redis-cli DEL products:top100

Results - throughput

Throughput (requests / second)GoNode.js

20 VU

/hello

3,984.1
2,407.6

/products

978.8
725.6

/products-where-like

554
392.6

/products-with-cache

1,156.9
905.6

100 VU

/hello

3,638.1
2,843.6

/products

1,113.1
861.4

/products-where-like

577.3
447.2

/products-with-cache

1,707.1
1,439.9

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

Links