Go API query optimization, measured
A complete query-site audit, the changes made from its execution plans, and a production-mode comparison with the legacy API on the same 35 GB Postgres mirror.
The detailed pass found two different performance problems. Several Go queries prevented PostgreSQL from using indexes that already existed, while two high-level handlers repeated the same database work many times. Fixing those paths changes player search and props screening by orders of magnitude. It does not change the referee aggregate, where both APIs still spend almost all of the request inside the same database-bound work.
Download the raw load-test CSV
59 production-mode measurements across five endpoints, two implementations, and six concurrency levels.
Read the earlier production analysis
Production index usage, pg_stat_statements pressure, and the database-wide constraints behind this pass.
Run the benchmark
The harness, endpoint shapes, output fields, and interpretation rules.
What changed
| Path | Before | After | Measured effect |
|---|---|---|---|
| Props ID filters | numeric[] cast against bigint columns | Validated bigint[] parameters | 31.2× more throughput at concurrency 1; 96.1× at concurrency 8 in the focused before/after run |
| Player search | Expression did not match the trigram index | Exact indexed normalization expression | 7.6× more throughput at concurrency 1; 13.5× at concurrency 8 |
| Tournament betting trends | 121 statements for a 20-team league | 7 batched statements | Warm median fell from 66.7 ms to 37.4 ms, 44% lower |
| Shots model | About 25 base statements and repeated team loads | 12 statements with batched windows | Base request fell from 70.2 ms to 21.5 ms in an alternating cold run |
| System stats latest row | Sort/scan on 1.7 million rows | Descending created_at index | Warm request fell from 233 ms to 41 ms |
| Positive value-bet count | Filtered positive-edge scan | Partial covering index | Warm request fell from 64.3 ms to 14.1 ms |
The props fix is a type fix, not a new cache. PostgreSQL cannot use a bigint
btree efficiently when the column is coerced through a numeric[] comparison.
The Go API now rejects fractional and out-of-range IDs from the SQL parameter;
those values cannot match a bigint ID. An all-invalid list becomes FALSE
instead of accidentally widening the request.
Player search had a similar expression mismatch. The database already had a
trigram index on immutable_unaccent(lower(players.name)), but the query used a
different normalization chain. The new predicate matches the indexed
expression exactly. EXPLAIN (ANALYZE, BUFFERS) now shows a bitmap index scan
and completes representative Arsenal and Alex searches in 1.5–1.6 ms.
Final API comparison
Both servers ran in production mode against the same local 35 GB PostgreSQL mirror. The table uses concurrency 8 because it keeps every endpoint below its timeout envelope and exposes useful throughput without comparing failed work.
| Endpoint | Go req/s | Legacy req/s | Throughput ratio | Go p50 | Legacy p50 |
|---|---|---|---|---|---|
| Health, no database | 24,373.0 | 2,665.1 | 9.1× | 0.3 ms | 2.5 ms |
| Models, small read | 14,226.5 | 462.3 | 30.8× | 0.5 ms | 15.4 ms |
| Indexed search | 191.8 | 5.8 | 33.1× | 39.8 ms | 1,055.8 ms |
| Referee aggregate | 15.4 | 15.3 | 1.0× | 496.1 ms | 486.8 ms |
| Props screener | 438.9 | 350.9 | 1.3× | 17.8 ms | 22.3 ms |
The search result is the largest query-specific improvement. The final Go build completes all 2,090 requests issued at concurrency 2,048, sustaining 194.1 successful requests per second. Legacy reaches the 20-second timeout at concurrency 128 and records 300 timeouts at concurrency 512.
The props screener also remains stable through concurrency 2,048: 534.1 successful requests per second with no failures, against 438.2 for legacy. Its gain is smaller because both implementations use the same ten-connection pool and the final query is already indexed.
The referee query remains database-bound
The referee endpoint is effectively tied at concurrency 8 and both versions begin timing out at concurrency 512. Porting the handler removed no meaningful database work. Its next material improvement requires a different aggregate strategy, such as maintaining or refreshing the per-referee summary, rather than another runtime-level rewrite.
How the pass covered the query surface
The static inventory covered all query construction under the Go API:
- 126 Go files containing database work
- 117
NewSelectsites and 209NewRawsites - 18 inserts, 19 updates, and 12 deletes
- 48 JSON row scans
- about 422 effective query construction sites after accounting for direct expressions and helper-generated statements
Every site was classified by read bound, parameter type, fan-out, ordering, and likely index path. Execution plans and endpoint timings were then taken for the high-risk shapes rather than treating source appearance as proof of cost. This is why the pass changed four query families and added four indexes, but did not add speculative indexes to every filtered column.
The batching changes preserve the legacy response. Sorted JSON from the old and new betting-trend and shots-model handlers matched exactly for the measured fixtures. Props defaults now match the legacy GET parser, including the default hit-rate and odds ranges. Search preserves the same top-ten IDs and order for the saved queries while supporting a broader set of accented names.
Load testing also exposed a concurrent panic in the shared name normalizer. A stateful text transformer had been reused across requests. Each normalization now owns its transformer, and the regression test passes under the Go race detector.
Indexes added from measured plans
The migration adds:
system_stats(created_at DESC)for the latest-row endpoint- partial covering indexes for positive over and under value-bet edges
- a partial
players("nationalTeamId")index for non-null national-team lookups
On the mirror, the system-stats plan is now an index scan that executes in 0.099 ms and touches 17 buffers. The positive-edge count uses an index-only scan with zero heap fetches and executes in 9.0 ms.
These indexes are additive and have been verified on the mirror. The migration has not been applied to production by this report.
Test design
| Variable | Setting |
|---|---|
| Database | Same 35 GB local PostgreSQL 17 mirror for both targets |
| Targets | Clean committed Go binary on port 8181; production legacy server on port 3000 |
| Runtime mode | APP_ENV=production for Go; NODE_ENV=production for legacy |
| Network | Loopback; client and servers on the same host |
| Database pool | Ten connections per implementation |
| Levels | 1, 8, 32, 128, 512, and 2,048 keep-alive connections |
| Level duration | Four seconds, after eight warm-up requests |
| Timeout | 20 seconds |
| Isolation | One endpoint and target at a time for database-heavy runs |
The first combined trial revealed an important harness failure mode: an HTTP timeout does not guarantee that the legacy database statement has stopped. Those statements can continue consuming the pool and contaminate the next endpoint. The published rows come from isolated runs. Database activity was checked between saturated runs and the next measurement started only after the active statement count returned to zero.
The explicit port is also important. Port 8080 is the shared debug server and logs every SQL statement; it is not a valid comparison with a production Next.js process. Every published Go row uses the production binary on port 8181.
Limits and remaining work
- Four-second levels locate throughput plateaus and saturation. They are not a soak test or a cloud capacity forecast.
- The client, both servers, and PostgreSQL shared one host. Absolute latency is less portable than the side-by-side result.
/api/player/allremains an unbounded public contract. On the 6.55 million appearance-row mirror, an unfiltered request exceeds 60 seconds. Fixing it safely needs an additive pagination or required-scope contract rather than a silent limit.- Production reports 162 never-scanned indexes totalling 3.7 GB, but this pass does not drop them. A production usage snapshot and concurrent removal plan are required before deleting schema objects.
- The referee aggregate is still the clearest read-path bottleneck. Both APIs converge at roughly 15–17 successful requests per second once the database pool is full.
Conclusion
The Go API no longer carries the two severe index-usage regressions found in this pass. Search moves from legacy's 5.8 requests per second to 191.8 at concurrency 8, and the final props endpoint closely tracks legacy while exceeding it at five of the six measured levels. Repeated query work in tournament trends and the shots model is cut roughly in half or better.
The remaining slow paths are now narrow and explicit: the referee aggregate, the unbounded player listing, and production-wide index cache pressure. They need contract or data-model changes, not another general rewrite of the Go runtime layer.
Classic web performance convergence
What was shared with the experimental UI, what remains separate, and the measured result of the PPR, cache, Nuqs, Suspense, and client-controller pass.
Nitro Fetch adoption research
Verified API, runtime limits, migration risks, and an adoption boundary for Nitro Fetch across the Expo applications.