Skip to content
Low Level Design Mastery Logo
LowLevelDesign Mastery

GraphQL Fundamentals

Ask for exactly what you need, get exactly that

GraphQL is a query language and runtime for APIs developed by Facebook (now Meta) in 2012 and open-sourced in 2015. Unlike REST APIs that return fixed data structures, GraphQL allows clients to specify exactly what data they need, reducing over-fetching and under-fetching.

Traditional REST APIs have a fundamental limitation: they return fixed data structures. If you need user data, you call /users/123 and get all user fields—even if you only need the name. If you need related data (like a user’s orders), you make multiple requests. This leads to:

  • Over-fetching: Getting data you don’t need (wasting bandwidth)
  • Under-fetching: Not getting enough data (requiring multiple requests)
  • Multiple round trips: Slower performance due to network latency

GraphQL solves this by providing a single endpoint where clients can request exactly the data they need, including related data, in a single request.

Diagram
  1. Single Endpoint - One URL for all operations
  2. Client-Specified Queries - Client decides what data to fetch
  3. Strongly Typed Schema - Schema defines all possible queries
  4. Introspection - Schema is self-documenting
  5. Real-time Subscriptions - WebSocket support for live updates

Diagram
GET /users/123 → Returns full user object
GET /users/123/orders → Returns all orders
GET /users/123/profile → Returns full profile

Problems:

  • Over-fetching (get data you don’t need) - Mobile app might only need name, but gets full user object with 20+ fields
  • Under-fetching (need multiple requests) - Need user and orders? That’s 2+ requests
  • Multiple round trips - Each request adds network latency, slowing down the app

GraphQL: Single Endpoint, Flexible Queries

Section titled “GraphQL: Single Endpoint, Flexible Queries”
query {
user(id: 123) {
name
email
orders {
id
total
}
}
}

Benefits:

  • Fetch exactly what you need - Request only the fields you need, nothing more
  • Get related data in one request - Fetch user and their orders in a single query
  • Single round trip - One network request instead of multiple, reducing latency

Diagram

Schema defines what data is available and how to query it.

type User {
id: ID!
name: String!
email: String!
orders: [Order!]!
}
type Order {
id: ID!
total: Float!
items: [OrderItem!]!
}
type Query {
user(id: ID!): User
users: [User!]!
}
type Mutation {
createUser(name: String!, email: String!): User!
updateUser(id: ID!, name: String): User!
}
TypeMeaningExample
StringText"John Doe"
IntInteger42
FloatDecimal99.99
BooleanTrue/Falsetrue
IDUnique identifier"123"
!Required (non-null)String!
[Type]Array[String!]!

Diagram

Queries are for reading data. They’re like GET requests in REST.

query {
user(id: "123") {
name
email
}
}

Response:

{
"data": {
"user": {
"name": "John Doe",
"email": "[email protected]"
}
}
}
query {
users(limit: 10, offset: 0) {
id
name
}
}
query {
user(id: "123") {
name
email
orders {
id
total
items {
product {
name
price
}
quantity
}
}
}
}

This single query replaces multiple REST calls:

  • GET /users/123
  • GET /users/123/orders
  • GET /orders/456/items
  • GET /products/789

Get multiple versions of same field:

query {
user1: user(id: "123") {
name
}
user2: user(id: "456") {
name
}
}

Reusable field sets:

fragment UserInfo on User {
id
name
email
}
query {
user(id: "123") {
...UserInfo
orders {
id
}
}
}

Diagram

Mutations are for creating, updating, or deleting data. Like POST/PUT/DELETE in REST.

mutation {
createUser(name: "Jane Doe", email: "[email protected]") {
id
name
email
}
}

Response:

{
"data": {
"createUser": {
"id": "456",
"name": "Jane Doe",
"email": "[email protected]"
}
}
}
mutation {
updateUser(id: "123", name: "John Smith") {
id
name
email
}
}
mutation {
deleteUser(id: "123") {
id
}
}

Execute multiple mutations in one request:

mutation {
createUser(name: "Alice", email: "[email protected]") {
id
}
createOrder(userId: "123", items: [...]) {
id
}
}

Diagram

Subscriptions provide real-time data using WebSockets.

subscription {
userUpdated(userId: "123") {
id
name
email
}
}

How it works:

  1. Client subscribes via WebSocket
  2. Server sends updates when data changes
  3. Client receives real-time updates
  • Live chat messages
  • Real-time notifications
  • Stock price updates
  • Collaborative editing
  • Live dashboards

Diagram

The biggest performance issue in GraphQL.

query {
users {
name
orders { # N+1 problem!
id
total
}
}
}

What happens:

  1. Query 1: SELECT * FROM users (gets 100 users)
  2. Query 2: SELECT * FROM orders WHERE user_id = 1
  3. Query 3: SELECT * FROM orders WHERE user_id = 2
  4. … (100 more queries!)

Total: 1 + 100 = 101 queries! This is the N+1 query problem—one query to get the list, then N queries (one per item) to get related data.

DataLoader batches requests:

Diagram

How DataLoader works:

  1. Collects all requests in a batch
  2. Waits for next event loop tick
  3. Executes single batched query
  4. Distributes results to individual requests

Diagram

Resolvers are functions that fetch data for each field.


Diagram
  • Multiple clients with different data needs - Web app needs all fields, mobile app needs minimal fields
  • Mobile apps where bandwidth matters - Reducing data transfer saves battery and improves performance
  • Complex relationships between data - Easier to fetch related data in one query
  • Rapidly evolving API requirements - Schema changes don’t break existing queries (backward compatible)
  • Real-time updates needed (subscriptions) - Built-in WebSocket support for live data
  • Simple CRUD operations - REST is simpler for basic create/read/update/delete
  • Caching is critical (HTTP caching) - REST benefits from HTTP caching infrastructure
  • File uploads (GraphQL handles this poorly) - REST is better suited for file operations
  • Simple APIs where over-fetching isn’t a problem - If bandwidth isn’t a concern, REST is simpler
  • Existing REST infrastructure - If you already have REST APIs, migration might not be worth it

Diagram

Bad:

query {
users { # Could return millions!
id
name
}
}

Why it’s bad: Without pagination, a query could return millions of records, causing performance issues, memory problems, and timeouts.

Good:

query {
users(first: 10, after: "cursor123") {
edges {
node {
id
name
}
}
pageInfo {
hasNextPage
endCursor
}
}
}

Prevent deeply nested queries:

MAX_QUERY_DEPTH = 10
def validate_query_depth(query, max_depth=MAX_QUERY_DEPTH):
depth = calculate_depth(query)
if depth > max_depth:
raise GraphQLError("Query too deep")

Authorize at field level:

@query.field("user")
def resolve_user(_, info, id: str):
user = user_repository.find_by_id(id)
# Check if user can access email field
if not can_access_field(info, "email"):
user.pop("email") # Remove email from response
return user

Prevent expensive queries:

def calculate_complexity(query):
complexity = 0
for field in query.fields:
complexity += field.complexity
if field.has_list:
complexity *= field.list_size
return complexity
if calculate_complexity(query) > MAX_COMPLEXITY:
raise GraphQLError("Query too complex")

Disable introspection in production (or limit it):

# Disable introspection
schema = make_executable_schema(type_defs, resolvers)
schema.introspection = False # In production

Diagram

At the code level, GraphQL translates to resolvers, schema definitions, and DataLoader patterns.


Ask for What You Need

GraphQL lets clients specify exactly what data they need, reducing over-fetching and under-fetching.

Watch for N+1

The N+1 query problem is GraphQL’s biggest performance issue. Use DataLoader to batch requests.

Resolvers Fetch Data

Resolvers are functions that fetch data for each field. They’re where your business logic lives.

Schema is Contract

GraphQL schema defines your API contract. It’s self-documenting and strongly typed.