RTP Stack Comparison: GraphQL vs REST vs Go/HTMX

Date: 2026-08-17 (updated) · Host: Arch Linux, Node v26.7, Go 1.26.5, PostgreSQL 18.4 Goal: Data-driven input for choosing a stack for a new CRUD project.

All three apps run against the same PostgreSQL database (SEPS + SHARED schemas, migrated from the Oracle dump via db-migrate) and are measured under identical load with vegeta, while a /proc sampler records server CPU% and RSS.

2026-08-17 update: Added rtp-api — a Node/Express/React version using plain REST endpoints instead of GraphQL. This isolates the overhead of the GraphQL layer itself (schema parsing, resolver chaining, introspection) from the Node runtime cost, giving a fairer comparison of API style vs runtime vs rendering strategy. Also fixed a stale session cookie in Go targets that was causing the detail page to return login redirects instead of actual content.


1. What was compared

Three representative CRUD read operations, served by each stack in its natural form (Node/GraphQL = JSON, Node/REST = JSON, Go = HTMX/HTML):

scenario operation rows returned
read-one fetch one employee by PK (+ appointments + stipends/payers/fund-sources) 1 (person + 5 appts)
read-many list all employees 1,183
read-small list vacancies 209

Each scenario: 3 s warmup, then 15 s of constant-rate load (100 connections / 100 workers).


2. Results

cpu is average over the window as fraction of one core (100% = one core; Go spreads over cores, Node is single-threaded so it peaks near ~100% per process).

2a. Light load — 100 rps (read-one/small), 20 rps (read-many)

run rate success p50 p95 p99 max resp cpu RSS(peak)
node-read-one 100 100% 3.16 ms 4.95 ms 6.06 ms 10.6 ms 4 KB 27.3% 200 MB
rtp-api-read-one 100 100% 4.74 ms 6.26 ms 7.16 ms 11.7 ms 10 KB 24.2% 123 MB
go-read-one 100 100% 8.19 ms 10.5 ms 12.5 ms 16.6 ms 79 KB 51.4% 21 MB
node-read-many 20 100% 7.33 ms 9.88 ms 12.8 ms 15.1 ms 114 KB 14.2% 211 MB
rtp-api-read-many 20 100% 4.06 ms 5.53 ms 6.77 ms 7.89 ms 190 KB 9.2% 157 MB
go-read-many 20 100% 21.4 ms 24.1 ms 25.3 ms 28.6 ms 175 KB 15.2% 21 MB
node-read-small 100 100% 1.87 ms 2.99 ms 3.83 ms 5.38 ms 14 KB 16.2% 231 MB
rtp-api-read-small 100 100% 1.43 ms 2.10 ms 2.52 ms 4.48 ms 47 KB 13.8% 161 MB
go-read-small 100 100% 2.61 ms 3.46 ms 3.88 ms 5.16 ms 47 KB 25.5% 22 MB

2b. Medium load — 400 rps (read-one/small), 40 rps (read-many)

run rate success p50 p95 p99 max resp cpu RSS(peak)
node-read-one 400 100% 4.02 ms 36.9 ms 65.6 ms 108.5 ms 4 KB 86.1% 316 MB
rtp-api-read-one 400 100% 5.25 ms 8.08 ms 11.3 ms 21.4 ms 10 KB 71.0% 179 MB
go-read-one 400 100% 11.5 ms 18.8 ms 24.8 ms 48.1 ms 79 KB 254.6% 26 MB
node-read-many 40 100% 7.49 ms 9.84 ms 11.5 ms 13.7 ms 114 KB 29.5% 316 MB
rtp-api-read-many 40 100% 3.86 ms 4.85 ms 5.50 ms 7.80 ms 190 KB 13.6% 193 MB
go-read-many 40 100% 21.7 ms 24.9 ms 26.2 ms 31.7 ms 175 KB 32.6% 25 MB
node-read-small 400 100% 1.55 ms 3.00 ms 5.13 ms 19.1 ms 14 KB 61.6% 229 MB
rtp-api-read-small 400 100% 1.00 ms 1.82 ms 2.84 ms 5.68 ms 47 KB 37.2% 216 MB
go-read-small 400 100% 2.52 ms 3.63 ms 4.35 ms 6.14 ms 47 KB 98.0% 25 MB

2c. High load — 1200 rps (read-one/small), 20 rps (read-many)

At 1200 rps the heavy read-one (a full person detail page) exceeds all three servers' capacity — numbers below are a saturation snapshot (vegeta waited up to 30 s), so the meaningful metrics are throughput and success, not latency.

run rate success p50 p95 max throughput cpu RSS(peak)
node-read-one 1083 70.5% 18.6 s 30.0 s 30.0 s 258 rps 72.4% 514 MB
rtp-api-read-one 1187 24.3% 583 ms 14.8 s 30.0 s 119 rps 24.3% 272 MB
go-read-one 1200 100% 12.4 s 12.7 s 12.8 s 654 rps 292.6% 593 MB
node-read-many 20 100% 7.85 ms 10.3 ms 15.3 ms 20 rps 28.0% 922 MB
rtp-api-read-many 20 100% 4.10 ms 4.98 ms 6.92 ms 20 rps 11.3% 293 MB
go-read-many 20 100% 21.9 ms 24.6 ms 30.1 ms 20 rps 16.4% 105 MB
node-read-small 1200 95.8% 5.00 s 15.5 s 30.0 s 388 rps 112.7% 882 MB
rtp-api-read-small 1200 100% 3.07 ms 24.1 ms 1.60 s 1199 rps 96.9% 259 MB
go-read-small 1200 100% 3.05 ms 5.41 ms 38.5 ms 1200 rps 326.1% 93 MB

At 1200 rps on the light vacancy list, Go held 100% success at ~3 ms while Node (GraphQL) dropped 4% of requests and collapsed to 388 rps throughput. REST held 100% success with comparable latency. On the heavy detail page all three saturated, but Go sustained 2.5x the throughput of GraphQL (654 vs 258 rps) and REST managed 119 rps (the /full endpoint is heavier than GraphQL's selective fields). Node's RSS climbed steadily through the session (514 -> 882 -> 922 MB) and never came back down; Go's spiked only during saturation runs and returned to ~93 MB afterwards.


3. Project size & complexity

measure rtp (Node/Express/React/GraphQL) rtp-api (Node/Express/React/REST) rtp-go (Go/HTMX)
Backend app code ~6,900 LOC (server/, ~6,600 generated db/) ~2,000 LOC (server/, no GraphQL) ~2,850 LOC (16 .go files)
Templates (API only -- React renders client-side) (API only -- React renders client-side) ~990 LOC (13 HTML templates)
Frontend ~12,100 LOC React SPA (src/, excl. test-data) ~8,000 LOC React SPA (src/, excl. test-data) server-rendered HTML (no JS build)
GraphQL code ~1,700 LOC resolvers + ~1,200 LOC schemas 0 (REST routes only) 0
Shared base library required nctr-baseapi ~2,300 LOC nctr-baseapi ~2,300 LOC none
Direct dependencies 19 (server) + 25 (front) + 14 (baseapi) 13 (server) + 23 (front) + 14 (baseapi) 1 (pgx; 5 transitive)
Installed packages 339 in server/node_modules ~280 in server/node_modules 6 Go modules
Dependency disk footprint 106 MB + 189 MB node_modules ~90 MB + 170 MB node_modules ~few MB module cache
Deployable artifact Node runtime + node_modules + built SPA Node runtime + node_modules + built SPA single 19 MB static binary
Build pipeline esbuild + postcss + tailwind + npm-force-resolutions esbuild + postcss + tailwind + npm-force-resolutions go build
Runtime RSS (light->saturated) 200 -> 922 MB 123 -> 293 MB 21 -> ~105 MB (brief 593 MB spike under saturation)

Fairness note: rtp-api has the same React frontend as rtp but uses plain REST endpoints (fetch()) instead of Apollo Client/GraphQL. This isolates the cost of the GraphQL layer itself: schema parsing, resolver chaining, introspection, and the Apollo Client cache. The backend is ~4,900 LOC smaller because it has no resolvers or schema definitions.


4. Analysis

Latency at light load -- Node beats Go on the detail page, Go wins on lists. Go's p50 on the detail page is 8.19 ms vs 3.16 ms (GraphQL) and 4.74 ms (REST) -- Go serves 79 KB of server-rendered HTML with ~26 N+1 DB queries vs Node's compact 4-10 KB JSON. The latency gap is dominated by payload size and query count, not runtime overhead. On the list endpoints Go is competitive (2.6 ms vs 1.9/1.4 ms) despite returning 47 KB of HTML vs 14/47 KB of JSON.

Latency at medium load -- REST's sweet spot. At 400 rps on the detail page, REST (5.25 ms p50) beats GraphQL (4.02 ms) on tail latency because it avoids resolver chaining overhead -- the /full endpoint assembles nested data in a single DB query. Go (11.5 ms p50) pays the N+1 tax but uses multi-core CPU (254%) to stay under 50 ms max. On the vacancy list, REST (1.00 ms) beats both GraphQL (1.55 ms) and Go (2.52 ms).

CPU -- REST uses 3% less CPU than GraphQL. At 100 rps on the detail page, REST uses 24.2% vs 27.3% -- a small but consistent advantage from skipping GraphQL schema parsing and resolver dispatch. Go uses 51.4% because it returns 79 KB of HTML (20x the GraphQL payload) but spreads it across cores.

Memory -- REST uses 38% less RSS than GraphQL. At 100 rps, REST peaks at 123 MB vs 200 MB for GraphQL -- a 77 MB savings from eliminating the Apollo Client cache, GraphQL schema objects, and resolver middleware. Go uses 21 MB (6x less than REST).

Payload -- GraphQL wins on compactness, REST over-fetches, Go returns HTML. GraphQL returns 4 KB for the detail page (client-specified fields), REST returns 10 KB (server-assembled nested data), Go returns 79 KB (full server-rendered page). The REST /full endpoint returns 2.5x more data than GraphQL because it includes all nested appointments/stipends/payers. Go returns 20x more because it's a full HTML page with inline CSS and navigation.

Capacity -- Go scales out, Node hits a wall. At 1200 rps on the vacancy list, Go held 100% success at ~3 ms while GraphQL dropped 4% of requests and collapsed to 388 rps. REST held 100% success with comparable latency (3.07 ms p50) -- the simpler endpoint and lighter runtime let Node/REST keep up on small payloads. On the heavy detail page, Go sustained 654 rps (100% success) vs GraphQL's 258 rps (70.5%) and REST's 119 rps (24.3%). Both Node stacks hit the same single-threaded event loop ceiling; REST collapsed earlier because its /full endpoint does more server-side work.

Operational / supply-chain. Go wins: one binary, one dependency, go build. rtp-api eliminates 8 GraphQL packages from rtp -- meaningful supply-chain reduction but still 300+ packages vs Go's 6 modules.

GraphQL vs REST -- the key finding. The GraphQL layer costs:

What does GraphQL buy you? Type safety, introspection, client-specified field selection, and a strong ecosystem for tooling. For a single-client CRUD app like RTP, these benefits are marginal. For a public API consumed by many clients, they are substantial.

REST vs Go/HTMX -- the surprise. REST is remarkably competitive with Go at light and medium load on small payloads. It only collapses at high load on the heavy detail page (24.3% success vs Go's 100%). For a CRUD app with moderate traffic, REST on Node gives you the React SPA experience with comparable latency to Go and far less memory than GraphQL. The cost: a build pipeline, a large dependency tree, and a single-threaded ceiling that Go avoids.


5. Caveats


6. Recommendation for a new CRUD project

Default to Go + HTMX + Postgres for internal tools, admin, and forms/tables/reports: dramatically lower and flatter memory (21 vs 123-200 MB, no runaway GC heap), a single static binary, one dependency, no frontend build, multi-core capacity headroom at 1200+ rps that Node's single thread cannot reach, and a comparable codebase (2,850 LOC + 990 templates). You get more capacity per host and far less operational/supply-chain surface.

Choose Node/Express/React/REST when you need a JSON API consumed by a single React client and want the best latency-to-complexity ratio. REST beats GraphQL on tail latency at medium load (5.25 ms vs 4.02 ms p50 at 400 rps) with 38% less memory, 3% less CPU, and ~4,900 fewer lines of backend code. Accept the cost: higher and growing memory, a build pipeline, a large dependency tree, and a single-threaded ceiling.

Choose Node/Express/React/GraphQL when you genuinely need: a rich, interactive SPA UX; a shared JSON/GraphQL API consumed by multiple clients; or deep existing React investment with codegen, Federation, or other GraphQL ecosystem tooling. The overhead over REST is small (~4,900 LOC, 8 packages) but real -- pay it only when the benefits (type safety, introspection, field selection) justify the cost.

Performance trade to watch: server-rendered HTML costs ~2x CPU and 5-20x bandwidth versus compact JSON, and Go's current detail page pays an N+1 query penalty. If latency and payload efficiency become the top priority (a heavy public API, very large lists), profile and batch the Go queries -- that is a code-level fix, not a stack-level one. Nothing in these results argues for staying on Node for a CRUD/forms app; the reasons to pick Node are UX, ecosystem, and team skill, not performance.


7. Reproduce

# servers (all three, already migrated to Postgres)
cd ~/projects/rtp/server && set -a && source ./.env && set +a && node server.mjs &      # :4100 (GraphQL)
cd ~/projects/rtp-api/server && set -a && source ./.env && set +a && node server.mjs &   # :4101 (REST)
cd ~/projects/rtp-go && ./rtp-go &                                                        # :4200 (Go/HTMX)

# Go pages require a session: login once and bake the cookie into targets/
curl -c /tmp/cj -d 'networkId=Cdavis' http://localhost:4200/login
# -> paste the rtp_sess value into the Cookie header of targets/go-*.txt

# benchmark (default = light load; results/ overwritten each run)
cd ~/projects/rtp-bench
DURATION=15s RATE_READ_ONE=400 RATE_READ_SMALL=400 RATE_READ_MANY=40 ./run-bench.sh
# raw vegeta reports: results/*.report ; resource samples: results/*.resources
# this session's snapshots are kept in results/{light,medium,high}/