GraphQL Fundamentals

GraphQL is a query language and runtime for APIs. The client specifies what data it needs; the server returns exactly that. A single endpoint replaces dozens of REST endpoints. The schema is first-class; tooling is excellent. The trade-off: more complex than REST.

This page is about how GraphQL works in practice and when to use it.

Schema

A GraphQL schema defines types and their fields:

type Order {
    id: ID!
    amount: Float!
    status: OrderStatus!
    customer: Customer!
    items: [OrderItem!]!
}

enum OrderStatus {
    PENDING
    SHIPPED
    DELIVERED
    CANCELLED
}

type Query {
    order(id: ID!): Order
    orders(status: OrderStatus, limit: Int = 50): [Order!]!
}

type Mutation {
    createOrder(input: CreateOrderInput!): Order!
    cancelOrder(id: ID!): Order!
}

The schema is the contract. Clients query it; servers implement it.

Queries

Clients request specific fields:

query {
    order(id: "abc") {
        id
        amount
        customer {
            name
            email
        }
    }
}

Response contains only the requested fields. No over-fetching.

Aliases

Same field, different arguments:

query {
    pending: orders(status: PENDING) { id }
    shipped: orders(status: SHIPPED) { id }
}

Fragments

Reusable field selections:

fragment OrderSummary on Order {
    id
    amount
    status
}

query {
    order(id: "1") { ...OrderSummary }
    orders { ...OrderSummary }
}

Variables

Parameterized queries (the right way; not string interpolation):

query GetOrder($id: ID!) {
    order(id: $id) { id amount }
}

Mutations

Operations that modify data:

mutation {
    createOrder(input: { customerId: "abc", amount: 100.00 }) {
        id
        status
    }
}

Same schema/query mechanics as queries; convention separates them so it's clear what modifies state.

Subscriptions

Real-time data via WebSocket (typically):

subscription {
    orderStatusChanged(orderId: "abc") {
        id
        status
    }
}

The connection stays open; the server pushes events. See WebSocketPatterns.

The N+1 problem

GraphQL's flexibility creates a server-side performance issue. Consider:

query {
    orders {
        id
        customer { name }
    }
}

Naive resolution:

  1. Fetch all orders (1 query)
  2. For each order, fetch its customer (N queries)

Total: N+1 queries.

DataLoader

The standard solution. DataLoader batches requests within a single tick of the event loop:

const customerLoader = new DataLoader(async (ids) => {
    const customers = await db.customers.findByIds(ids);
    return ids.map(id => customers.find(c => c.id === id));
});

// In resolver:
customer: (order) => customerLoader.load(order.customerId)

Each load() call queues the ID; at the end of the tick, all queued IDs are fetched in one query. N+1 becomes 2.

DataLoader is essential for any non-trivial GraphQL server.

Authorization

Field-level authorization is a real complication. Each field can have different permissions; the resolver enforces them.

Two patterns:

For complex authorization, schema-level approaches help; for simple cases, resolver checks are clearest.

Pagination

Three styles, in order of decreasing convenience and increasing scalability:

Offset/limit

orders(limit: 50, offset: 100): [Order!]!

Simple but inefficient at deep pages.

Cursor-based

orders(first: 50, after: "cursor123"): OrderConnection!

type OrderConnection {
    edges: [OrderEdge!]!
    pageInfo: PageInfo!
}

Relay-style. Faster for deep pagination; preserves order under inserts.

Page-based

orders(page: 5, perPage: 50): [Order!]!

Familiar; less efficient than cursor-based.

See PaginationStrategies.

When GraphQL wins

When REST wins

See ApiProtocolComparison.

Common failure patterns

Further Reading