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.

⚡ Quick Takeaways
  • 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.
tldr

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.

REST response
// 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:

GraphQL schema
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:

REST endpoints
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:

GraphQL schema
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:

Node.js / Express
// 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),
  }
}
takeaway

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.

JavaScript / Apollo Server
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.

why it's worse than REST

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.

JavaScript — DataLoader
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.

GraphQL SDL — cache hints
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.

HTTP — APQ flow
# 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
CDN strategy

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.

GraphQL SDL — mutation inputs
# 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.

GraphQL SDL — Relay connection
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 vs cursor tradeoff

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.

GraphQL — partial response
{
  "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.

GraphQL — subscription definition and client usage
# 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.

GraphQL SDL — Federation subgraphs
# 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

AspectFederationSchema Stitching
Type ownershipPer-subgraph, explicit with @keyMerged at gateway with custom transforms
Runtime couplingGateway fetches live subgraph SDL on startupSchemas stitched at build time or startup
Team autonomyHigh — each subgraph deploys independentlyMedium — merge logic lives centrally
Ecosystem supportApollo Router, GraphOS, strong toolinggraphql-tools; less opinionated
Query planningAutomatic distributed query planningManual link/delegate definitions
Best forLarge orgs with many independent teamsSmall-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.

JavaScript — graphql-depth-limit
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.

JavaScript — graphql-query-complexity
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.

security checklist

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.

GraphQL SDL — deprecation
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.

CriterionGraphQLRESTgRPC
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

interview tip

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.

🎯 interview hot-takes

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.

← previous
Spark Join Strategy