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 |
- Node/GraphQL (rtp):
POST /graphqlon port 4100 (Express 5 + graphql-yoga +nctr-baseapi).person(where:{nctrId:1649})(nested through appointments/stipends/payers/apptfs),person(all),sepsVac. - Node/REST (rtp-api):
GET /api/person/1649/full,GET /api/person,GET /api/sepsVacon port 4101 (Express 5 +nctr-baseapi, no GraphQL). Same React frontend, but usesfetch()instead of Apollo Client. The/fullendpoint assembles nested data server-side in a single response. - Go/HTMX (rtp-go):
GET /employee/{nctrId},GET /employee,GET /vacancyon port 4200 (net/http + html/template + pgx + HTMX). - Auth: every Go page is behind a login session (role-gated). A session cookie
(issued via
POST /login) is baked into the Go load targets; both Node APIs are open. All three hit the same Postgres DB.
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-apihas the same React frontend asrtpbut 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:
- ~4,900 LOC of resolvers and schema definitions
- 8 additional npm packages (server + client)
- 3% more CPU, 62% more memory, but 1.5x faster detail page latency at light load
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
- The heavy
read-one(full person detail page) is the bottleneck for all three stacks -- it is a much heavier operation than a single-row read. Real traffic rarely loads detail pages at 400-1200 rps, so the light table is the most representative. - Go pages were tested with a pre-issued session cookie (no per-request login overhead). The in-memory session store is single-process.
rtp-apiresults are from the same run as GraphQL and Go (same session, same DB). REST uses 38% less memory and 3% less CPU than GraphQL at identical load levels.- High-load RSS numbers reflect process state accumulated across the whole session (Node's especially); treat them as a ceiling, not per-scenario cost.
- Writes were not load-tested (GraphQL mutations are role-gated; Go writes verified functionally).
- SEPS views were not migrated (they reference
DFMNEW, which was never dumped). - Single-box test; no network hop, TLS, or reverse proxy in the path.
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}/