GraphQL

Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: An alternative to REST where the client says exactly what fields and shapes of data it wants in a single query, and the server returns precisely that — no more, no less.


In plain English

REST APIs make you ask a separate question for each “thing” you want. To build a blog post page, you might need:

GET /posts/47              → the post
GET /users/123             → the author
GET /posts/47/comments     → the comments
GET /users/{each}          → each commenter's profile

That’s 4+ round trips, and each endpoint returns whatever the server decided to include — often more data than you need (“over-fetching”) and sometimes less (“under-fetching,” forcing yet another call).

GraphQL is a different approach, designed at Facebook in 2012 and open-sourced in 2015. Instead of many endpoints, you have ONE endpoint (usually /graphql). Instead of fixed responses, the client sends a query describing exactly the fields and nested shape it wants. The server returns precisely that shape — never more, never less.

query {
  post(id: "47") {
    title
    body
    author {
      name
    }
    comments {
      body
      author {
        name
      }
    }
  }
}

Response:

{
  "data": {
    "post": {
      "title": "Hello",
      "body": "...",
      "author": { "name": "George" },
      "comments": [
        { "body": "Nice!", "author": { "name": "Alice" } },
        { "body": "+1",   "author": { "name": "Bob" } }
      ]
    }
  }
}

One request. Exactly the data you need. No extras. The server traversed the relationships behind the scenes.

That’s the elevator pitch. It’s powerful, it’s elegant, and it’s surrounded by tooling that makes typed end-to-end queries possible. It also has real operational complexity — discussed below.


Why it matters

Two reasons GraphQL is worth knowing even if you don’t pick it for your own API:

  1. Some services only expose GraphQL. Shopify’s Storefront API, GitHub’s v4 API, Hasura, AWS AppSync, Linear’s API — all GraphQL-first. If you integrate with these, you’ll write queries.

  2. For specific shapes of app, it’s genuinely the right tool. Heavily relational data (social networks, project management tools), mobile apps minimizing payload size, multi-team frontends consuming a shared backend — these are GraphQL sweet spots.

For the kinds of projects George builds, the modern stack (Next.js + Supabase + REST + server actions) usually doesn’t need GraphQL. But knowing GraphQL means you can spot when it WOULD be the right tool — and integrate with services that publish a GraphQL API.


The core building blocks

GraphQL has a small vocabulary you’ll see repeatedly:

TermWhat it means
SchemaThe strongly-typed contract: what types exist, what fields they have, what queries/mutations are allowed. Written in SDL (Schema Definition Language).
TypeA named object — User, Post, Comment. Has fields, each with its own type.
QueryA read-only operation. “Get me data with this shape.”
MutationA write operation. “Create this. Update this. Delete this.” Same syntax as a query, just declared as a mutation.
SubscriptionA streaming read. The server pushes updates over a websocket when data changes.
ResolverThe server-side function that fetches data for a specific field. Behind every field in the schema is a resolver.
IntrospectionThe schema itself is queryable — __schema, __type. This lets tools auto-generate types and explore the API.

A concrete example: a small GraphQL schema

type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}
 
type Post {
  id: ID!
  title: String!
  body: String!
  author: User!
  comments: [Comment!]!
  createdAt: String!
}
 
type Comment {
  id: ID!
  body: String!
  author: User!
  post: Post!
}
 
type Query {
  post(id: ID!): Post
  user(id: ID!): User
  recentPosts(limit: Int = 20): [Post!]!
}
 
type Mutation {
  createPost(title: String!, body: String!): Post!
  deletePost(id: ID!): Boolean!
}

Notes:

  • ! after a type means “non-null” — this field is guaranteed to exist.
  • [Post!]! means “a non-null list of non-null Posts” (the list itself can’t be null, and no item can be null).
  • Int = 20 is a default value for that parameter.

A client can then write:

query {
  recentPosts(limit: 5) {
    title
    author { name }
  }
}

The server’s resolver for recentPosts runs first, returns 5 posts. The resolver for each Post.author runs next, returning the User. Each leaf field’s value lands in the response. The client gets exactly what it asked for.


Mutations look the same but write

mutation {
  createPost(title: "Hello", body: "World") {
    id
    createdAt
  }
}

This creates a post AND returns the new id + creation time in one round trip. The convention: a mutation returns the new state of the resource, so the client can update its local cache without a separate read.


How GraphQL hits the wire

GraphQL is just HTTP POST under the hood. A request looks like:

POST /graphql HTTP/1.1
Host: api.example.com
Authorization: Bearer abc123
Content-Type: application/json
 
{
  "query": "query GetPost($id: ID!) { post(id: $id) { title body } }",
  "variables": { "id": "47" },
  "operationName": "GetPost"
}

The body is JSON: the query string + any variables. Variables are passed separately (not interpolated into the query) to prevent injection attacks.

The response is JSON. If there are errors, they appear in a sibling errors array:

{
  "data": { "post": null },
  "errors": [
    { "message": "Post not found", "path": ["post"], "extensions": { "code": "NOT_FOUND" } }
  ]
}

A key oddity: GraphQL almost always returns HTTP 200 OK even on errors. The error info lives in the errors array, not the HTTP status. This is part of “you might have data AND errors in the same response” — for instance, when fetching multiple things and only one fails.


The strongly-typed end-to-end story

The killer feature of GraphQL in 2026 is type safety from server to client. The flow:

  1. Server defines its schema in SDL (or generates it from code).
  2. Tools (codegen) read the schema + your client queries and generate TypeScript types matching the exact shape of each query’s response.
  3. Your IDE autocompletes fields. Your typechecker catches typos in queries. Refactoring is safe.

In practice, with graphql-codegen:

import { useQuery } from "@apollo/client";
import { GET_POST_QUERY, GetPostQueryResult } from "./generated";
 
const { data, loading } = useQuery<GetPostQueryResult>(GET_POST_QUERY, {
  variables: { id: "47" },
});
 
// data.post.title is typed!
// data.post.author.name is typed!
// data.post.notARealField is a compile error

This level of cross-stack typing is genuinely valuable for big teams or rapidly changing schemas. REST gets close with OpenAPI codegen but with more friction.


Caching is harder

REST has a key cache advantage: the URL identifies a resource. Hit the same URL → get the same cached response. HTTP caches and CDNs work effortlessly.

GraphQL: every query is a POST with a different body. Most HTTP caches won’t cache POSTs. You can’t just put a CDN in front.

Solutions:

  • Persisted queries — assign each unique query a hash. Send the hash + variables instead of the full query. Cache by hash + variables. Apollo, Relay, and others support this.
  • Client-side normalized caches — Apollo Client, Relay, urql build smart caches in the browser, keyed by the type + id of every object. A User fetched in one query is reused for another.
  • Automatic Persisted Queries (APQ) — the client sends a hash; if the server doesn’t recognize it, it asks for the full query, which it then caches by hash.
  • Server-side caching — DataLoader-style batching collapses repeated DB lookups within a request; Redis can cache resolver results.

These work, but they require deliberate engineering. With REST, default HTTP caching just works.


The N+1 problem and DataLoader

A specific GraphQL hazard: a poorly-written resolver can fetch the same data hundreds of times.

query {
  recentPosts {            # 1 query to fetch posts
    title
    author { name }        # N queries — one per post!
  }
}

Without help, the resolver for Post.author runs once per post. 100 posts = 101 database queries. This is the classic “N+1 problem.”

DataLoader is the standard solution. It batches lookups within a single request: “we asked for users with ids [1, 2, 3, 1, 2]” → DataLoader dedupes and issues SELECT * FROM users WHERE id IN (1, 2, 3) once. Resolvers ask DataLoader instead of the DB directly.

Every serious GraphQL server uses DataLoader (or similar). If you write a resolver that fetches data per-item naively, you’ve created a performance disaster.


Subscriptions — realtime over GraphQL

A third operation type, subscription, lets clients receive updates pushed by the server:

subscription {
  newCommentsOnPost(postId: "47") {
    body
    author { name }
  }
}

The client opens a persistent connection (websocket or SSE). When the server has a matching event, it pushes the data.

Useful for chat, presence, live dashboards, collaborative editing. More complex to operate than queries: connection management, scaling, auth across long-lived connections.

For most webapps, dedicated realtime tools (Supabase Realtime, Pusher, Ably) are simpler than GraphQL Subscriptions unless you’re already deep in the GraphQL ecosystem.


Tools and frameworks

The GraphQL ecosystem is mature:

ToolPurpose
ApolloMost popular GraphQL toolkit — server, client, codegen, federation
RelayFacebook’s GraphQL client, advanced pagination/caching, opinionated
urqlLighter, more flexible client alternative
graphql-codegenGenerate TypeScript types from schema + queries
HasuraAuto-generate a GraphQL API from a Postgres database
PostGraphileSimilar — Postgres → GraphQL automatically
GraphQL YogaModern, simple GraphQL server
MercuriusFast GraphQL server for Fastify
PothosTypeScript-first schema builder, very type-safe
Apollo FederationCompose multiple GraphQL services into one super-graph

For a hand-built GraphQL API in Node/TypeScript in 2026, Pothos + GraphQL Yoga + DataLoader + graphql-codegen is the modern stack. For “I just want GraphQL over Postgres,” Hasura or PostGraphile are auto-generated.


GraphQL vs REST — the honest comparison

AspectGraphQLREST
Shape of responseDefined by clientDefined by server
Over-fetchingEliminatedCommon
Round trips for related dataOne (often)Many (often)
HTTP cachingHardEasy
Tooling for typesExcellent (codegen, introspection)Good (OpenAPI codegen)
Learning curveSteeperGentler
Operational complexityHigher (N+1, complexity attacks, persisted queries)Lower
File uploadsAwkward (multipart spec)Native
Common use casesHighly relational data, mobile, multi-team frontendsResource CRUD, public APIs, simple webapps

GraphQL is not a “better REST.” It’s a different shape that fits specific use cases. For the kinds of small-to-medium webapps George builds, REST + typed server actions is simpler and equally effective. For Shopify’s storefront, GitHub’s developer ecosystem, or Linear’s API, GraphQL is genuinely the right choice.


Common gotchas

  • Always-200 HTTP responses. GraphQL signals errors in the body, not the status code. Don’t write client code that only checks response.ok. Always look at errors.

  • N+1 queries are easy to write and hard to spot. Every resolver that touches the database needs to be DataLoader-aware. Profile early.

  • Schema complexity attacks. An attacker can write { user { posts { author { posts { author { posts ... } } } } } } to make the server do exponential work. Use query depth limiting and complexity analysis (graphql-cost-analysis, graphql-query-complexity) on production endpoints.

  • Introspection on production is a double-edged sword. Public introspection lets attackers map your entire API. Some teams disable it in production; others leave it on to encourage exploration. Pick deliberately.

  • Mutations should return useful data. A mutation that returns Boolean forces the client to refetch. A mutation that returns the updated resource lets the client update its cache. Always return the resource (or its new state).

  • Updating cache after mutations is error-prone. Apollo’s normalized cache helps, but list-shaped data (e.g. adding a new post) often needs explicit cache updates. Read the cache update docs of your chosen client.

  • GraphQL is one endpoint — all auth + rate limiting must happen there. REST naturally spreads requests across many URLs; GraphQL concentrates them. Per-resolver auth checks; per-user query complexity budgets.

  • Subscriptions need a websocket layer. Serverless functions don’t naturally hold connections open. Subscriptions usually require a longer-lived server or a managed service (Pusher, AppSync).

  • File uploads are awkward. GraphQL’s native types are strings, ints, etc. Uploads use multipart-form extensions (graphql-upload), pre-signed URLs (upload to S3 first, send the URL via GraphQL), or REST endpoints next to the GraphQL one.

  • Persisted queries are operational overhead. Caching benefits require the client to know its query hashes ahead of time. Most teams adopt this only after they hit performance walls.

  • Generated TypeScript types can lie. If the server returns null for a field declared non-null, your code crashes at runtime despite types saying it’s safe. Trust the schema; verify at boundaries.

  • GraphQL doesn’t enforce CORS or rate limits. The middleware around GraphQL does. Make sure they’re applied.

  • Stale codegen vs live schema. If the server schema changes and the client codegen hasn’t run, you get build-time type mismatches. Run codegen in CI.

  • The N+1 problem applies to mutations too. A mutation that iterates and calls another mutation per item can detonate.

  • Schema federation has a learning curve. Federating multiple GraphQL services into one super-graph (Apollo Federation, GraphQL Mesh) is powerful but complex. Don’t reach for it until you’ve outgrown a monolithic schema.

  • Error messages can leak internals. A naive resolver that returns error.message from a database driver can reveal table names, query structure, library versions. Sanitize before returning errors.

  • Pagination is a footgun. Naive { posts { ... } } returns ALL posts. Forces every caller to over-fetch. Make list fields require explicit pagination (first, after for cursor-based) from day one.

  • Schema-first vs code-first. Two ways to define schema: write SDL files (schema-first) or generate SDL from code (code-first). Both work; code-first (Pothos, Nexus) wins for type safety; schema-first wins for cross-language teams. Pick early.

  • The community has fragmented. Apollo dominates, but each year a new “Apollo killer” emerges. Stick with the popular tools unless you have a specific need.

  • Performance characteristics depend on resolvers, not GraphQL itself. A slow GraphQL API is a slow database with GraphQL on top. Profile resolvers, optimize queries, batch with DataLoader.

  • Don’t expose your database directly as GraphQL without thought. Hasura and PostGraphile are great, but exposing every table and every relationship gives clients enormous power. Layer auth, complexity limits, and view-level controls.


When to choose GraphQL

  • Heavily relational data that frontend teams need to traverse flexibly
  • Multiple frontend clients (web, iOS, Android) consuming the same backend with different needs
  • Rapid iteration where adding/removing fields shouldn’t require backend API versions
  • Mobile apps where round-trip minimization and payload size really matter
  • Integrating with a service that publishes GraphQL (Shopify, GitHub, Linear)

When NOT to choose GraphQL

  • Simple CRUD with a few obvious endpoints
  • Public API where caching is the priority (REST + CDN is much simpler)
  • Solo project that doesn’t need its API to be language-agnostic (typed server actions or tRPC are simpler and equally type-safe)
  • You’re early-stage and just need to ship

See also


Sources