API

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.

What was measured

Four endpoints, cheapest first, so the numbers separate request overhead from query cost:

EndpointShapeWhy it is here
/api/healthNo databaseWhatever it costs is the runtime's floor.
/api/modelsSmall readA query that returns in under a millisecond.
/api/search?q=arsenalIndexed searchSix tables, ilike, an index behind it.
/api/referees/list?limit=100AggregateThe 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.

ConcurrencyGo rpsNext.js rpsRatioGo p50Next.js p50
111,32328939×0.1 ms1.4 ms
822,91392225×0.3 ms3.8 ms
3224,8331,71414×1.2 ms8.0 ms
12821,7252,4565.4 ms26.3 ms
51221,9383,88622.9 ms95.9 ms
2,04821,6505,20689.9 ms386.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 servers

Ten 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 endpoint

Memory, 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=go

It 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.

On this page