No description
  • Go 31.3%
  • Rust 29%
  • Python 21.9%
  • JavaScript 17.8%
Find a file
Vincent Batts 53dcb37729 Add four GraphQL demo stacks: JS, Go, Rust, Python
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
2026-09-15 14:12:07 -04:00
go Add four GraphQL demo stacks: JS, Go, Rust, Python 2026-09-15 14:12:07 -04:00
js Add four GraphQL demo stacks: JS, Go, Rust, Python 2026-09-15 14:12:07 -04:00
python Add four GraphQL demo stacks: JS, Go, Rust, Python 2026-09-15 14:12:07 -04:00
rust Add four GraphQL demo stacks: JS, Go, Rust, Python 2026-09-15 14:12:07 -04:00
.gitignore Add four GraphQL demo stacks: JS, Go, Rust, Python 2026-09-15 14:12:07 -04:00
README.md Add four GraphQL demo stacks: JS, Go, Rust, Python 2026-09-15 14:12:07 -04:00

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

  1. 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.NewObject in go/server/schema.go (code-first — no schema string).
    • Rust: derived from Rust structs/impls via #[Object] macros in rust/server/src/schema.rs (code-first; the schema is generated from the types at compile time).
    • Python: derived from type-hinted classes via @strawberry.type in python/server/schema.py (also code-first — closest in feel to Rust's approach, just with runtime types instead of compile-time ones).
  2. 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 id and name and the books resolver (and its SQL query) never fires.
  3. 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 the errors array, not the status code (see the last query in each client, which asks for a nonexistent author).

What the client has to do

  1. 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).
  2. POST it as JSON — {query: "...", variables: {...}}. That's it; no client library is required, as js/client/client.js (raw fetch), go/client/main.go (raw net/http), rust/client/src/main.rs (raw reqwest), and python/client/client.py (raw stdlib urllib) 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.
  3. Read {data, errors} back and handle the fact that data can be partially populated alongside errors (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 nested books — 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.go is 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 Author doesn't break existing clients that don't ask for it — there's no /v2/authors needed 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 topAuthors query that returns many authors and watch books resolve 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 POST to the same URL with a body, so CDN/browser caching keyed on URL+method (trivial for REST GET /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 -I your way to a 404; the client must always parse errors out 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 single net/http REST 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 def resolvers throughout and pairing them with an async DB driver (e.g. asyncpg, not sync sqlite3) — 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:

  1. 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.
  2. 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.
  3. 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.