- Go 31.3%
- Rust 29%
- Python 21.9%
- JavaScript 17.8%
A hands-on comparison of GraphQL's client/server contract across four
languages, each with its own idiomatic combo and independent server/client:
- js/ Node.js + graphql-yoga (schema-first SDL) + better-sqlite3
- go/ Go + graphql-go (code-first) + modernc.org/sqlite (pure Go, no cgo)
- rust/ Rust + async-graphql (code-first, macro-derived) + axum + rusqlite
- python/ Python + strawberry-graphql (code-first, type-hinted) + FastAPI/
uvicorn + stdlib sqlite3
All four servers expose the identical schema (authors have books, books
have an author, one addBook mutation) over the identical seeded SQLite
data (3 authors, 7 books), so runs are directly comparable rather than
just similar. Each server reseeds its own library.db on startup.
Every server requires a hardcoded X-API-Key header
(demo-secret-api-key-123) checked before the query touches the schema,
as a stand-in for real auth (documented in the README as explicitly not
representative of real key management). Every client demonstrates the
401 rejection first, then runs a set of representative queries/mutations:
field-selection-only query, a nested query pulling related data in one
round trip, a variabled query, a mutation, and a not-found query to show
the {data, errors} response shape.
README documents, per demo: what the server has to do (schema, resolvers,
single HTTP endpoint) vs. what the client has to do (build a query string,
POST JSON, read {data, errors}); the concrete benefits/drawbacks this
makes visible (over/under-fetching, N+1-per-field, HTTP caching, query
cost); and a language/runtime comparison for industry adoption and
read/write concurrency, including that SQLite's single-writer behavior
(not the GraphQL library) is the real bottleneck in all four demos as
built.
Known issue: js/client/client.js and python/client/client.py currently
default to the wrong ports (4001 and 4002, i.e. the Go and Rust servers'
ports, instead of their own servers' 4000 and 4003) — not yet fixed
pending confirmation this wasn't an intentional edit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rDLHcPMXf69MNGqmkTe9g
|
||
|---|---|---|
| go | ||
| js | ||
| python | ||
| rust | ||
| .gitignore | ||
| README.md | ||
GraphQL, hands-on: four servers, four clients
Four independent demos of the same idea — JavaScript, Go, Rust, and Python — so you can compare the shape of GraphQL without a language getting in the way:
graphql-demo/
js/server Node.js + graphql-yoga + better-sqlite3
js/client Node.js, plain fetch()
go/server Go + graphql-go + modernc.org/sqlite (pure Go, no cgo)
go/client Go, plain net/http
rust/server Rust + async-graphql + axum + rusqlite (bundled sqlite)
rust/client Rust, plain reqwest
python/server Python + strawberry-graphql + FastAPI/uvicorn + sqlite3 (stdlib)
python/client Python, plain urllib (stdlib)
All four servers expose the same schema (authors have books, books have an author, one mutation to add a book) backed by the same seeded SQLite data, so every run should produce identical output. Compare them side by side to see what's GraphQL-shaped and what's just language idiom.
Run it
JS:
cd js/server && npm install && npm start # http://localhost:4000/graphql
cd js/client && node client.js
Go:
cd go/server && go run . # http://localhost:4001/graphql
cd go/client && go run .
Rust:
cd rust/server && cargo run # http://localhost:4002/graphql
cd rust/client && cargo run
Python:
cd python/server
python3 -m venv .venv && ./.venv/bin/pip install -r requirements.txt
./.venv/bin/python app.py # http://localhost:4003/graphql
cd python/client && python3 client.py # stdlib only, no install needed
Each server wipes and reseeds its own library.db on startup, so runs are
repeatable. The JS, Rust, and Python servers all serve an interactive GraphiQL
UI if you open their URL in a browser — try typing queries by hand and using
the schema autocomplete/docs panel. The Go server here is POST-only (no UI),
to show that a GraphiQL-style playground is a nice-to-have the server chooses
to bundle, not something GraphQL requires.
All four servers require a hardcoded API key on every query
(demo-secret-api-key-123, sent as an X-API-Key header) — see
"Authentication" below. Every client sends it automatically; to try a
server's GraphiQL UI in a browser, add { "x-api-key": "demo-secret-api-key-123" }
under its "Headers" tab first, or queries there will 401.
What the server has to do
- Define a schema — the types (
Author,Book), their fields, and the entry points (Query,Mutation). This is the contract with clients, and it's introspectable at runtime (that's what powers GraphiQL's autocomplete).- JS: written as an SDL string in
js/server/schema.js(schema-first). - Go: built programmatically with
graphql.NewObjectingo/server/schema.go(code-first — no schema string). - Rust: derived from Rust structs/impls via
#[Object]macros inrust/server/src/schema.rs(code-first; the schema is generated from the types at compile time). - Python: derived from type-hinted classes via
@strawberry.typeinpython/server/schema.py(also code-first — closest in feel to Rust's approach, just with runtime types instead of compile-time ones).
- JS: written as an SDL string in
- Write resolvers — one function per field, saying how to produce that
field's value from whatever source it needs (SQL here, but could be
another service, a cache, a computed value). Critically: a resolver only
runs if the client's query actually asked for that field. Ask for just
idandnameand thebooksresolver (and its SQL query) never fires. - Expose one HTTP endpoint — conventionally
POST /graphql, accepting{query, variables}JSON and returning{data, errors}JSON. There's no per-resource routing like REST; every request goes to the same URL and the query body decides what happens. Notice the response is HTTP 200 even when a field errored — GraphQL puts error detail in theerrorsarray, not the status code (see the last query in each client, which asks for a nonexistent author).
What the client has to do
- Write a query string naming exactly the fields it wants, including
nested ones (
author { books { title } }in one request — no separate round trip to "look up the books for this author" the way you'd often see in REST). - POST it as JSON —
{query: "...", variables: {...}}. That's it; no client library is required, asjs/client/client.js(rawfetch),go/client/main.go(rawnet/http),rust/client/src/main.rs(rawreqwest), andpython/client/client.py(raw stdliburllib) all show. Real-world GraphQL client libraries (Apollo Client, urql, genqlient) mainly add normalized caching and generated types on top of this — they don't change the wire protocol. - Read
{data, errors}back and handle the fact thatdatacan be partially populated alongsideerrors(a field can fail without the whole request failing).
Authentication
All four servers reject any POST /graphql that doesn't carry
X-API-Key: demo-secret-api-key-123 with a 401 before the query ever
touches the schema — see js/server/server.js, go/server/main.go,
rust/server/src/main.rs, and python/server/app.py for the check, and each
client's first request, which deliberately omits the key to show the
rejection.
This is a hardcoded stand-in for the real thing so you can see where the check lives (in front of resolution, looking at a header) without wiring up a real identity provider. It intentionally does not reflect industry practice for the key itself — a real deployment issues one key per client, stores a hash of it server-side (not the raw value), and supports rotation/ revocation. For how real GraphQL APIs typically handle this (OAuth2/OIDC + JWT bearer tokens, field-level authorization via directives or resolver guards, gateway-terminated auth in federated setups), that's a separate, meatier addition — ask if you want that layered in next.
What this makes visible about GraphQL
Benefits you can see in the demo:
- No over/under-fetching. The first query in each client asks for just
id/name; the second asks for the same authors plus nestedbooks— same endpoint, client controls the shape, and nested/related data comes back in one round trip instead of one request per author (the classic REST "N+1 endpoint calls" problem). - One typed contract, self-describing. The schema in
schema.js/schema.gois the documentation, and it's machine-readable — GraphiQL's autocomplete reads it live. Compare to needing a separate OpenAPI/Swagger doc kept in sync with a REST API by hand. - Evolvable without versioning. Adding a field to
Authordoesn't break existing clients that don't ask for it — there's no/v2/authorsneeded the way REST often ends up with.
Drawbacks/costs you'd hit going further with this:
- The N+1 query problem moves, it doesn't disappear. Try adding a
topAuthorsquery that returns many authors and watchbooksresolve once per author — that's one SQL query per author under the hood. Real GraphQL servers solve this with batching (DataLoader in JS, similar patterns in Go), which is extra machinery REST's per-endpoint caching doesn't need. - HTTP caching mostly stops working. Every request is a
POSTto the same URL with a body, so CDN/browser caching keyed on URL+method (trivial for RESTGET /authors/1) needs GraphQL-aware tooling (persisted queries, response caching by query+variables hash) to get back. - Errors are structural, not transport-level. You can't
curl -Iyour way to a 404; the client must always parseerrorsout of a 200 response, which pushes error handling into application code on both ends. - A client can ask for expensive things. Because the client composes the query, a deeply nested or highly repeated query can be much more expensive than the server author expected (query depth/complexity limiting is a real concern for any public GraphQL API — not implemented in this toy demo).
- More upfront server plumbing. Compare
go/server's three files to what a singlenet/httpREST handler for "get an author and their books" would look like — GraphQL trades a bit of server-side ceremony for the client flexibility above.
Which language/runtime is the "industry standard" for a real GraphQL API?
There isn't one — but the four demos here map onto four real, load-bearing choices, and they trade off differently:
- Node.js (Apollo Server / GraphQL Yoga) has the largest GraphQL-specific ecosystem by far — Apollo Federation for splitting a schema across services, Apollo Studio for schema registry/metrics, the most GraphQL client libraries, the most job-market familiarity. If "industry standard" means "what would a new hire have already seen," this is it. Concurrency model: single-threaded event loop — great for I/O-bound resolvers (fetch from a DB/API and wait), bad if a resolver does real CPU work (it blocks everyone else).
- Go (gqlgen / graphql-go) is the common choice when a team wants GraphQL and wants to stop worrying about throughput — goroutines give cheap real concurrency (thousands of in-flight requests without thread overhead), it compiles to one static binary, and it's what a lot of infra-conscious orgs reach for once a Node GraphQL service becomes a bottleneck. Smaller GraphQL-specific ecosystem than Apollo's, but the language itself is extremely industry-standard for backend services.
- Rust (async-graphql / juniper) gives you the best latency and resource footprint of the four — no GC pauses, no interpreter, real parallelism — but it's the least common choice specifically for GraphQL in industry today. Teams pick it when they've already decided they need Rust's performance ceiling for the whole service, not because of GraphQL tooling. Smallest hiring pool of the four for this stack.
- Python (Strawberry / Graphene) has genuine production usage, mostly at
companies already Python-heavy (Django/FastAPI shops, data/ML orgs) who
want a GraphQL layer without introducing a second language. The catch for
your stated goal — responsive, efficient under concurrent reads/writes —
is the GIL: sync resolvers (like the ones in this demo) block the whole
process on every request. You can get real concurrency in Python GraphQL
servers, but only by writing
async defresolvers throughout and pairing them with an async DB driver (e.g.asyncpg, not syncsqlite3) — it's achievable, just not the path of least resistance the way it is in Node, Go, or Rust.
For your actual question — responsive, efficient reads and writes — the GraphQL library is not where that gets decided; the data layer is:
- SQLite itself is the bottleneck in all four demos, not the language. SQLite serializes writers (and the Rust/Go/Python demos additionally wrap the whole connection in a single mutex/lock for simplicity) — every write in every one of these four servers blocks every other write. A real API server handling concurrent writes wants a client-server database (Postgres, MySQL) with a real connection pool, in any of these four languages.
- The N+1-per-field problem (noted above) needs batching — DataLoader in Node, similar patterns in Go/Rust/Python — regardless of which language you pick. Skipping this is the single most common reason a "working" GraphQL server becomes an unresponsive one under load.
- Given those two are fixed, Go and Rust have the highest concurrency ceiling or the four (no GIL, no single-threaded event loop to starve), Node has the most mature operational tooling for a GraphQL API specifically, and Python is the one that needs the most deliberate effort (fully async resolvers + async drivers) to avoid becoming a concurrency bottleneck.
If you want to see the difference land, the next step would be turning
addBook into something that holds a connection for a noticeably long time
and load-testing concurrent reads against it in two of these servers — ask if
you'd like that added.