GraphQL is often portrayed as a revolutionary approach to API design. Rather than interacting with fixed server-defined endpoints, it lets you send queries to retrieve precisely the data you need in a single request. While this is true, in practice both GraphQL and REST transmit HTTP requests and receive JSON results. The real differences are subtler — and more interesting.
- REST — resource identity is the URL; the server decides the response shape; reads use GET, writes use POST/PUT/PATCH/DELETE.
- GraphQL — client specifies exactly which fields to return in a single query; decouples resource shape from how you fetch it via a typed schema.
- Over-fetching vs under-fetching — REST commonly returns too many fields or requires multiple round trips; GraphQL solves both with a single precise query.
- Introspection — GraphQL schemas are self-describing; clients can query the schema itself. REST requires external tools like Swagger/OpenAPI.
- Use GraphQL for complex, deeply nested data needs (mobile apps, product pages); use REST for simple CRUD APIs, public APIs, or when HTTP caching is critical.
GraphQL and REST are not as different as they seem. GraphQL introduces subtle modifications — mainly around how resources are described and fetched — that significantly improve the developer experience.
Resources
The core concept of REST is the resource. Each resource is uniquely identified by a URL, and you access it by sending a GET request to that URL.
// GET /api/movies/123
{
"title": "Oppenheimer",
"director": {
"name": "Christopher Nolan",
"dob": "1970/07/30"
},
"released": "2023/08/30"
}
GraphQL decouples the shape of a resource from how you fetch it. You define types in a schema independently of retrieval methods:
type Movie {
id: ID
title: String
released: Date
director: Director
}
type Director {
id: ID
name: String
dob: Date
movies: [Movie]
}
type Query {
movie(id: ID!): Movie
director(id: ID!): Director
}
API Design
REST APIs are described as a catalogue of endpoints. The shape is linear — a flat list of URLs you can call:
GET /movies/:id
GET /director/:id
GET /movies/:id/review
POST /movies/:id/review
GraphQL instead uses a schema — a typed graph of everything accessible in the API. Read operations use Query, writes use Mutation:
type Query {
movie(id: ID!): Movie
director(id: ID!): Director
}
type Mutation {
addMovieReview(input: AddMovieReviewInput): Review
}
Key Differences at a Glance
| Aspect | GraphQL | REST |
|---|---|---|
| Resource identity | Separate from how you fetch it | The URL IS the resource identity |
| Response shape | Client decides what fields to return | Server decides what to include |
| Related data | One request via schema traversal | Multiple requests or custom params |
| Read vs write | Query / Mutation keywords | GET / POST / PUT / DELETE verbs |
| Schema / Docs | Built-in introspection | External tools (Swagger, OpenAPI) |
Request Handlers vs Resolvers
In REST, each endpoint maps to a handler function. Here's a simple Express example:
// REST — one handler per endpoint
app.get('/hello', function(req, res) {
res.send('Hello World!')
})
// GraphQL — one resolver per field
const resolvers = {
Query: {
movie: (parent, { id }) => getMovieById(id),
director: (parent, { id }) => getDirectorById(id),
}
}
The biggest practical difference: GraphQL lets you fetch deeply nested related data in a single request, while REST typically requires multiple round trips or over-fetching. For complex frontend data needs, GraphQL wins. For simple CRUD APIs with predictable clients, REST is often cleaner.
The Resolver Execution Model
Understanding how GraphQL actually runs your query — field by field — is essential for writing servers that perform well. When a request arrives, the GraphQL runtime parses the query document into an abstract syntax tree, validates it against the schema, and then executes it by calling a resolver function for every selected field. Resolvers are pure functions with the signature (parent, args, context, info): parent is the resolved value of the parent field, args are query arguments, context is a request-scoped object (auth token, DataLoader instances, DB connections), and info exposes the AST of the current field.
Execution is depth-first: the root Query fields resolve first, then the runtime fans out to resolve each child field in parallel within the same depth level. This means sibling fields at the same level can resolve concurrently, but a child field cannot start until its parent has returned a value. The practical implication is that deep query trees will chain resolver calls, and any synchronous IO in a resolver blocks the entire chain.
const resolvers = {
Query: {
// root resolver — receives null parent, query args
movies: async (parent, args, context) => {
return context.db.query('SELECT * FROM movies LIMIT ?', [args.limit])
},
},
Movie: {
// field resolver — parent is the movie row from above
director: async (movie, args, context) => {
return context.db.query('SELECT * FROM directors WHERE id = ?', [movie.directorId])
},
},
}
The N+1 Problem
The resolver model has a well-known failure mode. If you query a list of 100 movies and each movie has a director field, the GraphQL runtime calls the Movie.director resolver 100 separate times — once per movie object. Each call fires a new database query. That's 1 query for the list plus 100 queries for the directors: the classic N+1 problem. At scale this turns a sub-millisecond operation into hundreds of sequential round trips.
A REST handler for GET /movies typically does a single JOIN or a manually batched lookup under the engineer's control. GraphQL's per-field resolver architecture makes the N+1 pattern the default outcome if you don't actively fight it — every nested field is a potential landmine.
DataLoader: Batching and Per-Request Caching
Facebook's DataLoader library (now standard across every GraphQL ecosystem) solves N+1 with two mechanisms working together. First, it batches all load calls within a single event-loop tick into a single batch function that you supply — so 100 individual directorLoader.load(id) calls become one call to your batch function with an array of 100 IDs. Second, it memoizes results for the duration of the request, so loading the same director ID twice from different parts of the query only hits the database once.
Critically, DataLoader instances should be created per request inside the context factory — sharing a DataLoader across requests would leak data between users.
import DataLoader from 'dataloader'
// batch function: called once per tick with all accumulated keys
const batchDirectors = async (directorIds) => {
const rows = await db.query(
'SELECT * FROM directors WHERE id = ANY(?)',
[directorIds]
)
// DataLoader requires results in the SAME ORDER as keys
const byId = Object.fromEntries(rows.map(r => [r.id, r]))
return directorIds.map(id => byId[id] ?? new Error(`Director ${id} not found`))
}
// created per-request in Apollo's context factory
const server = new ApolloServer({
resolvers: {
Movie: {
director: (movie, _, context) =>
context.directorLoader.load(movie.directorId), // batched!
},
},
context: () => ({
directorLoader: new DataLoader(batchDirectors), // fresh per request
}),
})
With this setup, 100 calls to directorLoader.load(id) within a single request coalesce into one SQL query with 100 IDs. If the same director ID appears multiple times (several movies by the same director), the memoization layer returns the cached value without touching the database at all. The result: 1 query for movies + 1 batched query for all distinct directors, regardless of result set size.
Caching: GraphQL's Hardest Problem
REST inherits decades of HTTP caching infrastructure for free. A GET /movies/123 response can be cached by browsers, proxies, and CDNs keyed on the URL; Cache-Control, ETag, and Last-Modified headers wire everything up automatically. GraphQL throws this out because almost all GraphQL requests go over POST to a single endpoint — HTTP caches treat all POSTs as uncacheable by default. This is not a trivial problem; CDN caching alone can absorb 90%+ of read traffic on a well-designed REST API.
Layer 1 — Application-Level Caching
The most portable solution is to cache at the data layer, not the HTTP layer. DataLoader already provides per-request memoization (described above). For cross-request caching, resolvers can check Redis before hitting the database. This is the same cache-aside pattern used in REST, just shifted into the resolver rather than the controller.
A more GraphQL-native approach is response field-level caching using cache hints. Apollo Server's @cacheControl directive lets you annotate individual types or fields with a max-age and scope (PUBLIC or PRIVATE). The server computes the minimum cache hint across all resolved fields and emits a Cache-Control header accordingly — allowing shared caches to cache the response if all fields are public and have a non-zero max-age.
type Movie @cacheControl(maxAge: 3600, scope: PUBLIC) {
id: ID!
title: String!
director: Director!
}
type Director @cacheControl(maxAge: 86400, scope: PUBLIC) {
id: ID!
name: String!
}
type Viewer @cacheControl(maxAge: 0, scope: PRIVATE) {
// user-specific — never share across users
watchlist: [Movie!]!
}
Layer 2 — Persisted Queries
Automatic Persisted Queries (APQ) solve two problems simultaneously: they convert POST requests into GET requests (unlocking HTTP caches and CDNs) and they shrink request bodies by sending a query hash instead of the full query string. The protocol works in two steps. The client first sends a GET with just the hash; if the server has seen it before, it executes the cached query. If not, the server returns a PersistedQueryNotFound error, the client retries as a POST with the full query body, and the server stores the query keyed by its hash for future requests.
# Step 1: client sends hash only (GET = cacheable by CDN)
GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"abc123"}}
# Step 2: server returns error if hash not found
{ "errors": [{ "message": "PersistedQueryNotFound" }] }
# Step 3: client retries with full query body (POST)
POST /graphql
{ "query": "{ movies { title director { name } } }",
"extensions": { "persistedQuery": { "sha256Hash": "abc123" } } }
# Subsequent requests: GET with hash → CDN cache hit
With APQ + @cacheControl hints, fully public queries (no user-specific data) can be served from a CDN edge node just like REST. The catch: a single query mixing public movie data with the viewer's watchlist gets the worst cache hint (maxAge 0, PRIVATE) and bypasses the CDN entirely. Design queries to separate public from private data, or split them into two requests.
Schema Design Best Practices
A GraphQL schema is a public contract — changing it breaks clients. Thoughtful schema design prevents painful migrations later and makes the API intuitive to use.
Nullable by Default vs Non-Null by Default
In GraphQL SDL, every field is nullable by default; the ! suffix marks a field as non-null. The common mistake is over-using ! for "performance" or ergonomics and then discovering that a missing nested resource forces you to return null all the way up the tree, making fields you promised non-null into a runtime error. A principled rule: mark a field non-null only if the server can always provide it — primary keys, timestamps that are always set, fields on a freshly created object. Nullable fields signal "this might legitimately be absent."
Design for the Client, Not the Database
GraphQL schemas should reflect the product's domain model, not a 1:1 projection of the database schema. If the database has separate first_name and last_name columns but every client concatenates them, expose a single fullName field in the schema and do the concatenation in the resolver. This keeps business logic in one place and prevents clients from reinventing the same transformation independently.
Input Types for Mutations
Always wrap mutation arguments in a dedicated input type rather than listing arguments inline. This allows you to add fields to the input type over time without changing the mutation signature, keeps argument lists from becoming unwieldy, and makes the schema self-documenting about what a mutation expects.
# Avoid: inline args break when you need to add fields
type Mutation {
createMovie(title: String!, releaseYear: Int!): Movie
}
# Prefer: named input type — additive changes are non-breaking
input CreateMovieInput {
title: String!
releaseYear: Int!
tagline: String # added later — no breaking change
}
type Mutation {
createMovie(input: CreateMovieInput!): CreateMoviePayload!
}
type CreateMoviePayload {
movie: Movie
errors: [UserError!] # domain errors in the payload, not HTTP errors
}
Return Payload Types for Mutations
The pattern above also shows returning a payload type rather than the entity directly. This lets mutations return both the result entity and any user-facing validation errors in a single response, without abusing the HTTP error layer. It also leaves room to add ancillary data (affected counts, side-effected objects) without a breaking change.
Pagination: Cursor-Based and the Relay Connection Spec
REST typically paginates via ?page=2&limit=20 (offset pagination) or a next_cursor field in the response. Offset pagination has a well-known flaw: if items are inserted or deleted between pages, you'll see duplicates or skip items. Cursor-based pagination — using an opaque pointer to a position in the result set — is stable regardless of mutations between requests.
GraphQL codified this into the Relay Connection Specification, which has become an unofficial standard across the ecosystem. The connection pattern wraps a list of nodes in a typed envelope that carries pagination metadata alongside the data.
type Query {
movies(first: Int, after: String, last: Int, before: String): MovieConnection!
}
type MovieConnection {
edges: [MovieEdge]
pageInfo: PageInfo!
}
type MovieEdge {
node: Movie!
cursor: String! # opaque base64 position token
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
A client fetching the next page passes after: pageInfo.endCursor. The server decodes the cursor (typically a base64-encoded row ID or timestamp), applies it as a WHERE id > :cursor clause, and returns the next page. This is stable regardless of concurrent inserts. The edges wrapper exists to carry per-edge metadata (e.g., when a user followed a user-to-user relationship edge), which is why the data is not directly a flat array of nodes.
Offset pagination supports random-access page jumps (jump to page 50), which cursor pagination cannot. If your UI has numbered pages or needs a "go to page N" feature, offset pagination is pragmatically fine — just accept that results may shift between pages. Cursor pagination is the correct default for infinite-scroll feeds, timelines, and any list where stability matters more than random access.
Error Handling and Partial Responses
GraphQL's error model is fundamentally different from REST's. In REST, an error typically produces a non-200 status code and the response body is either empty or a single error object — the entire request either succeeds or fails. GraphQL almost always returns HTTP 200, even when resolver errors occur. Instead, errors appear in a top-level errors array alongside the data object. This enables partial responses: if one field's resolver fails but others succeed, the client receives all the data that could be resolved alongside error details for the part that failed.
{
"data": {
"movies": [
{ "title": "Oppenheimer", "director": { "name": "Christopher Nolan" } },
{ "title": "Dune Part Two", "director": null } // resolver failed
]
},
"errors": [
{
"message": "Director not found",
"locations": [{ "line": 4, "column": 5 }],
"path": ["movies", 1, "director"],
"extensions": { "code": "NOT_FOUND" }
}
]
}
The path field pinpoints exactly which field in which list item failed. The extensions object is the standard place for machine-readable error codes that clients can branch on — never put sensitive stack traces in extensions in production. The partial-response model is a double-edged sword: clients receive useful partial data, but they must always check the errors array even on a 200 response, which is counterintuitive to teams coming from REST.
User Errors vs System Errors
A useful pattern in production schemas is to distinguish between user errors (input validation failures, "email already taken") and system errors (database unavailable, unhandled exception). System errors surface in the top-level errors array. User errors should be modeled as domain types within the mutation's payload — they are expected outcomes, not exceptions, so encoding them in the schema makes them self-documenting and type-safe for client codegen.
Subscriptions: Real-Time GraphQL
REST has no built-in mechanism for server-pushed updates; teams bolt on WebSockets, SSE, or long-polling as out-of-band protocols. GraphQL has a first-class Subscription operation type that integrates real-time events into the same schema and query language as reads and writes.
A subscription opens a persistent connection (typically over WebSocket using the graphql-ws protocol) and streams a new result whenever the subscribed event occurs. The client writes a subscription document that looks exactly like a query — selecting only the fields it needs — and the server sends only those fields with each event.
# Schema
type Subscription {
orderStatusChanged(orderId: ID!): Order!
newMessage(conversationId: ID!): Message!
}
# Client subscribes — receives a stream of Order updates
subscription TrackOrder($orderId: ID!) {
orderStatusChanged(orderId: $orderId) {
id
status
updatedAt
estimatedDelivery
}
}
On the server, subscriptions are backed by a pub-sub engine. Apollo Server supports an in-process PubSub for development and Redis- or Kafka-backed pub-sub for production. When a mutation changes order status, it publishes to a topic keyed by orderId; the subscription resolver listens to that topic and filters incoming events by the subscribed ID before streaming the result to the connected client.
Subscriptions carry operational overhead: each active subscriber holds a WebSocket connection, and a server restart disconnects all clients. In production, you typically place a stateless WebSocket gateway (e.g., AWS API Gateway WebSockets, or a dedicated subscription server) in front of stateless application servers, using Redis pub-sub as the shared event bus.
Federation and Schema Stitching
At scale — especially in a microservices organization — a single monolithic GraphQL schema becomes a coordination bottleneck. Every team that wants to expose data must submit a PR to the same schema file. Schema federation solves this by letting each service own its own GraphQL subgraph while a central gateway composes them into a unified supergraph that clients query as a single API.
Apollo Federation
Apollo Federation (the dominant implementation) uses SDL directives to annotate how types are shared across subgraphs. A type can be defined in one subgraph and extended in another. The gateway inspects the query plan, dispatches subqueries to the appropriate subgraphs in parallel where possible, and stitches the results together before responding to the client.
# movies-subgraph: defines Movie, owns it
type Movie @key(fields: "id") {
id: ID!
title: String!
released: Int!
}
# reviews-subgraph: extends Movie with reviews field
extend type Movie @key(fields: "id") {
id: ID! @external # owned by movies-subgraph
reviews: [Review!]! # contributed by reviews-subgraph
}
type Review {
id: ID!
rating: Int!
comment: String
}
When a client queries movie { title reviews { rating } }, the gateway sends title to the movies-subgraph and reviews to the reviews-subgraph (using the movie's id as the key to join), then merges the results. From the client's perspective there is one API. Each team deploys their subgraph independently — adding a field to Review requires no coordination with the movies team.
Schema Stitching vs Federation
| Aspect | Federation | Schema Stitching |
|---|---|---|
| Type ownership | Per-subgraph, explicit with @key | Merged at gateway with custom transforms |
| Runtime coupling | Gateway fetches live subgraph SDL on startup | Schemas stitched at build time or startup |
| Team autonomy | High — each subgraph deploys independently | Medium — merge logic lives centrally |
| Ecosystem support | Apollo Router, GraphOS, strong tooling | graphql-tools; less opinionated |
| Query planning | Automatic distributed query planning | Manual link/delegate definitions |
| Best for | Large orgs with many independent teams | Small-scale schema composition, gradual migration |
Security: Limiting Query Complexity
GraphQL's flexibility is its attack surface. A malicious — or merely careless — client can send a single query that generates an exponentially expensive execution on the server. Unlike REST, where the server controls the cost of every endpoint, in GraphQL the client composes the query, and a deeply nested or cyclically structured query can trigger millions of resolver calls.
Query Depth Limiting
The simplest protection is capping how deeply nested a query can be. A depth of 10 is generous for virtually any real product use case; anything deeper is almost certainly a bug or an attack. Depth limiting is cheap to enforce — it requires only a traversal of the query AST before execution begins, adding negligible overhead.
import depthLimit from 'graphql-depth-limit'
const server = new ApolloServer({
schema,
validationRules: [
depthLimit(10), // reject queries deeper than 10 levels
],
})
Query Complexity Analysis
Depth alone is insufficient — a wide query (requesting 1000 fields at depth 2) can be just as expensive as a deep one. Complexity analysis assigns a numeric cost to each field and rejects queries whose total cost exceeds a budget. Field costs can be static (every field costs 1) or dynamic (a list field costs proportional to its first argument). This lets you say "this API will execute queries up to cost 1000 per request" — effectively a budget that encompasses both depth and breadth.
import { createComplexityLimitRule } from 'graphql-query-complexity'
const server = new ApolloServer({
schema,
validationRules: [
createComplexityLimitRule(1000, {
scalarCost: 1, // leaf fields cost 1
objectCost: 2, // object fields cost 2
listFactor: 10, // list multiplier — first:50 costs 50×child
onCost: (cost) => {
metrics.histogram('graphql.query_cost', cost)
},
}),
],
})
Rate Limiting and Persisted Query Allowlisting
Beyond per-query analysis, apply rate limiting at the API gateway level — per-IP and per-authenticated-user token buckets are the standard approach. In production APIs where you control all clients (a first-party mobile app or SPA), persisted query allowlisting is the strongest security posture: the server only executes queries whose hash is in an approved registry; arbitrary query strings are rejected entirely. This effectively turns GraphQL into a typed REST API during production while retaining full flexibility during development.
Disable introspection in production (it maps your entire schema for attackers). Set a query depth limit (10 is a sane default). Set a complexity budget. Apply per-user rate limits at the gateway. Use persisted query allowlisting if clients are first-party. Never trust client-supplied field aliases to bypass resolver authorization — check auth in every resolver that touches sensitive data, not just at the root.
Versioning Philosophy
REST versioning is typically handled at the URL level (/api/v1/, /api/v2/), with the previous version kept alive until clients migrate. This is explicit but creates maintenance overhead: two versions of every endpoint run concurrently, bugs must be fixed in both, and deprecation is a coordination exercise across all consumers.
GraphQL takes a different philosophical stance: schemas should evolve without versioning. The SDL's deprecation mechanism lets you mark fields with @deprecated(reason: "Use newField instead") without removing them. Clients that don't request old fields are unaffected; clients on old fields see the deprecation warning in introspection and tooling. New fields are additive and non-breaking. The schema evolves continuously rather than in versioned snapshots.
type Movie {
id: ID!
title: String!
releaseYear: Int!
# old field kept alive, clients nudged to migrate
year: Int @deprecated(reason: "Use releaseYear instead")
}
The practical limit of this philosophy is removing types or fields — GraphQL has no safe way to do that without potentially breaking existing clients. In practice, fields accumulate in mature schemas. Discipline requires tracking which clients request which fields (Apollo Studio's field usage analytics does this) and running a grace period before removal. The end state — a schema full of deprecated fields no one uses — is the GraphQL equivalent of a REST API that never retires old versions.
REST vs GraphQL vs gRPC — The Decision Matrix
Real systems rarely live purely in one paradigm. Understanding when each is the right tool is what separates senior engineers from those who reach for GraphQL by default.
| Criterion | GraphQL | REST | gRPC |
|---|---|---|---|
| Primary transport | HTTP/1.1 (WebSocket for subscriptions) | HTTP/1.1 or HTTP/2 | HTTP/2 (binary frames) |
| Data format | JSON | JSON (or XML, form data) | Protocol Buffers (binary) |
| Schema / contract | SDL — introspectable at runtime | OpenAPI/Swagger — optional, external | .proto files — strict, code-generated |
| Performance | Medium — JSON parsing overhead; single round trip for complex queries | Medium — JSON; multiple round trips for nested data | High — binary encoding, multiplexed streams, ~5–10× smaller payload |
| HTTP caching | Difficult (POST by default; requires APQ + CDN tricks) | Native (GET responses cache by URL) | None — not designed for HTTP caching |
| Streaming / real-time | Subscriptions (WebSocket) | SSE / long-polling / WebSocket (bolted on) | Native bidirectional streaming |
| Browser support | Full — works in any browser | Full — the native browser API | Limited — requires grpc-web proxy |
| Over/under-fetching | Eliminated — client selects fields | Common — server decides response shape | None — generated clients match proto exactly |
| Best for | Complex frontends, mobile with varied field needs, BFF (Backend for Frontend) | Public APIs, simple CRUD, HTTP cache-critical reads, file uploads | Internal microservice-to-microservice calls, high-throughput, polyglot backends |
| Operational complexity | High — schema federation, DataLoader, persisted queries, N+1 vigilance | Low — well-understood, vast tooling ecosystem | Medium — proto management, breaking change discipline across services |
Practical Heuristics
- Use GraphQL when you have multiple client types (web, iOS, Android, partner integrations) with meaningfully different data needs, or when a product page requires data from many resources in a single low-latency request. The canonical use case is a BFF (Backend for Frontend) layer that aggregates multiple microservices into a client-optimized graph.
- Use REST for public APIs where consumers are unknown and HTTP caching is valuable; for simple CRUD services with predictable access patterns; for file uploads or responses where content negotiation matters; and for teams who want the least operational overhead and the widest ecosystem of middleware.
- Use gRPC for internal service-to-service communication where performance is critical, payload size matters, you need bidirectional streaming, or you want strict contract enforcement enforced at compile time. gRPC is rarely exposed directly to browsers — a translation layer (gRPC-Gateway, gRPC-web, or a REST/GraphQL facade) sits in front.
- Hybrid architectures are normal: a GraphQL API gateway facing clients, gRPC between internal microservices, and REST for third-party webhooks and public integrations is a common and sensible combination. Pick the protocol whose tradeoffs fit the specific communication channel, not the one that fits the whole system.
When asked "GraphQL or REST?" in a system design interview, never pick one unconditionally. State what you're optimizing for: if the client has variable data needs and you control both sides, GraphQL. If it's a public API or you need HTTP caching to scale reads cheaply, REST. If it's internal service-to-service with latency SLOs, gRPC. Demonstrating awareness of all three and their tradeoffs is what earns senior-level credit.
GraphQL vs REST — when would you pick GraphQL? When clients have complex, varied data needs (e.g., a mobile app and web app needing different fields from the same data) — GraphQL eliminates over-fetching and multiple round trips with a single typed query.
What is the N+1 problem in GraphQL? A query for a list of items where each item triggers a separate resolver call — fetching 100 movies each fires a separate DB query for the director. The fix is DataLoader: it batches all .load(id) calls within a single event-loop tick into one batch function, then memoizes results for the request's lifetime, collapsing N queries into 1.
REST advantage over GraphQL? HTTP caching is straightforward with REST (GET requests cache by URL); GraphQL uses POST by default, making HTTP-level caching harder. REST is also simpler for public APIs with stable, predictable consumers.
How do you handle caching in GraphQL? Three layers: DataLoader for per-request memoization; Redis in resolvers for cross-request caching; Automatic Persisted Queries (APQ) to convert POST to GET so CDNs can cache public queries. Annotate types with @cacheControl to set max-age per field.
How do you prevent a malicious client from sending a query that DoS's your GraphQL server? Depth limiting (reject queries deeper than N levels), complexity analysis (assign a numeric cost per field, reject over budget), rate limiting per user at the gateway, and in production: persisted query allowlisting so only known query hashes are executed.
When would you use gRPC instead of GraphQL? Internal microservice communication where binary encoding, bidirectional streaming, and strict proto contracts matter more than developer ergonomics or browser compatibility.