Go against the Next.js routes
Both stacks on one box and one database, loaded from 1 to 2,048 concurrent connections until they stopped answering. Go serves 39× the requests on work that touches no database, and exactly the same number on work that does.
The Go service is a route-for-route port of the 218 Next.js route files it replaced, and both are still runnable side by side against one local Postgres mirror. That makes a controlled comparison possible: same machine, same database, same queries, one variable — the runtime holding the socket.
This is that run. 2026-08-28, 16 cores, 61 GB, both servers on loopback, escalating from 1 to 2,048 concurrent keep-alive connections, six seconds at each level, until something stopped answering.
Two things came out of it, and only one is the one you would expect.
Raw CSV
46 rows: target, endpoint, concurrency, throughput, p50/p90/p99/max, timeouts.
Parity stack
How both implementations come to be running against one database in the first place.
What was measured
Four endpoints, cheapest first, so the numbers separate request overhead from query cost:
| Endpoint | Shape | Why it is here |
|---|---|---|
/api/health | No database | Whatever it costs is the runtime's floor. |
/api/models | Small read | A query that returns in under a millisecond. |
/api/search?q=arsenal | Indexed search | Six tables, ilike, an index behind it. |
/api/referees/list?limit=100 | Aggregate | The kind of query a board waits on. |
Targets run one at a time — a box cannot measure two servers competing for its own cores — and each pair is warmed with eight requests first, so nothing here includes a cold compile.
Requests the database never sees
This is the part that matches the expectation, and the margin is larger than the porting work assumed.
| Concurrency | Go rps | Next.js rps | Ratio | Go p50 | Next.js p50 |
|---|---|---|---|---|---|
| 1 | 11,323 | 289 | 39× | 0.1 ms | 1.4 ms |
| 8 | 22,913 | 922 | 25× | 0.3 ms | 3.8 ms |
| 32 | 24,833 | 1,714 | 14× | 1.2 ms | 8.0 ms |
| 128 | 21,725 | 2,456 | 9× | 5.4 ms | 26.3 ms |
| 512 | 21,938 | 3,886 | 6× | 22.9 ms | 95.9 ms |
| 2,048 | 21,650 | 5,206 | 4× | 89.9 ms | 386.6 ms |
The ratio narrows as load climbs, and that is not Go slowing down. Go is at its ceiling by 32 connections — 24,833/s, then flat while latency grows linearly, which is what a saturated CPU looks like when nothing is queued badly. Node climbs the whole way because a single event loop is more efficient with a deep queue than a shallow one: it batches. It is still four times behind at the point where it is doing its best work.
/api/models, one small query, holds the same shape at a lower ceiling: 14,096/s
against 1,672/s, and a p50 of 0.5 ms against 8.3 ms at 8 connections.
Requests the database does see
Here is the finding that was not expected.
Both stacks converge, and on this endpoint the Go port is the slower one:
852 ms against 464 ms at a single connection, and 5.4/s against 12/s at eight.
On /api/search the two are within a few percent of each other at every level —
6.3/s against 5.7/s, 15/s against 12.9/s, 13/s against 12.2/s.
The reason is in both configurations, and it is the same number:
// apps/statshub-api/internal/database/database.go
func poolLimits(serverless bool) (maxConns int, idleTimeout, maxLifetime time.Duration) {
if serverless {
return 1, 20 * time.Second, 10 * time.Minute
}
return 10, 30 * time.Second, 30 * time.Minute
}// apps/web-legacy/src/db/index.ts
max: isServerless ? 1 : 10, // More connections for persistent serversTen connections each. Past ten in-flight queries, neither server is running anything — both are a queue in front of the same ten seats, and a queue does not care what language it is written in. The runtime stopped being the bottleneck somewhere around the third endpoint in this table, and everything after that measures Postgres.
The slower aggregate is a query, not a runtime
/api/referees/list being ~2× slower under Go is a real regression and it is
worth chasing, but nothing in this run points at the runtime: at one connection
there is no contention to lose to, and the same server serves /api/health
39× faster. It is the SQL the port emits. That is a different investigation, and
the measured query analysis
is where it starts.
Where each one stops answering
At 512 connections both stacks cross the client's 30-second timeout on both database-bound endpoints: 322 of 529 requests time out on Go, 238 of 578 on Next.js. Neither process dies — they are queueing, not failing — and both answer a health probe immediately afterwards.
The no-database endpoints never break at all. Go holds 21,650/s at 2,048 connections with a 90 ms p50 and not one error; Next.js holds 5,206/s with a 387 ms p50 and not one error.
There is one exception, and it happened during an early pass rather than in the
recorded run: the Next.js process exited entirely under the aggregate
endpoint, and every subsequent request was ECONNREFUSED until it was
restarted. The harness now separates that case from a timeout, because they
mean opposite things — one is a slow server, the other is no server:
// A timeout is a server that is too slow to answer; a refused or reset
// connection is a server that is not there. Only the second one ends the run.
if (row.refused > row.requests / 2) break outer; // process gone
if (row.timeouts > row.requests / 2) break; // saturated, next endpointMemory, at the end of the run: 262 MB resident for the Go binary, 2,095 MB
for next-server. Eight times the footprint to serve a quarter of the requests.
What this does and does not prove
Read plainly, because the setup limits what can be claimed:
- Client and servers share sixteen cores. At 2,048 connections the load generator is competing with the thing it measures. Both targets pay that tax, but the absolute ceilings are lower than dedicated hardware would give.
- The database is a local mirror, not production, and it was serving other services on the same box throughout.
- Six seconds per level is enough to see a plateau and not enough to see a leak, a GC pathology, or anything that needs a minute to show up.
What survives all of that: on work that does not touch the database, the port
is worth between 4× and 39× depending on how deep the queue is, at an eighth of
the memory. On work that does, the pool size is the only number that matters
and it is the same on both sides — so the next throughput win is not in either
runtime, it is in poolLimits.
Running it again
# Terminal one: Go on :8080
bun run build:api
bun run start:api
# Terminal two: Next.js on :3000
bun run build:web-legacy
bun run start:web-legacy
# Terminal three: run the comparison
node apps/statshub-api/scripts/bench-vs-legacy.mjs --out .bench
# a narrower pass
node apps/statshub-api/scripts/bench-vs-legacy.mjs \
--levels=1,64,1024 --duration=10 --endpoints=health,models --only=goIt writes api-vs-legacy.csv and a JSON file with the same rows plus the host
and the levels. Both servers are probed before every level, and the run stops
by itself if one of them stops answering.

