REST, GraphQL and gRPC are three ways for one program to ask another for data over a network. REST exposes resources at URLs and leans on HTTP’s methods, status codes and caching. GraphQL exposes one typed graph at a single endpoint and lets the client name the fields it wants. gRPC exposes typed procedures defined in Protocol Buffers, called over HTTP/2 with binary messages and streaming. None wins everywhere, so large systems use all three: REST at the public edge, GraphQL or a backend-for-frontend for apps, gRPC between internal services.
Picture it first: a food court, a waiter and a kitchen ticket
Picture a food court with separate counters for dosas, biryani and desserts.
The counters are REST. Anyone can order “item 7” from any counter’s menu board, no training needed, and popular items sit pre-packed at the front. But a meal from three counters means three queues, and each counter hands over its full combo even when you only wanted the chutney.
The waiter is GraphQL. You write everything you want, from every counter, on one slip; the waiter walks the kitchen and brings back one tray holding exactly that. But nothing can be pre-packed, because every slip differs, and a prankster can write “every dish, twice”, so slips need a size rule.
The kitchen ticket is gRPC. Cooks don’t shout dish names; they pass KOTs (kitchen order tickets) with numbered codes from a shared code book, each stamped “out by 8:42”. When 8:42 passes, every station drops that ticket. Fast and unambiguous, but customers can’t read KOTs.
Map it back: counters are resources, the menu board is an OpenAPI document, pre-packed items are HTTP caches and CDNs. The waiter is a GraphQL server running resolvers, the slip is a query, the slip-size rule is cost limiting. The KOT is a protobuf message, the code book is the .proto file, the stamp is a gRPC deadline, and unreadable KOTs are why browsers need gRPC-Web.
REST: resources over HTTP’s uniform interface
REST comes from Roy Fielding’s 2000 doctoral dissertation, which described the Web’s architecture as a set of constraints, each adopted for a property it gives the system:
- Client-server. The user interface and data storage evolve independently.
- Stateless. Every request carries everything needed to understand it. Any server can take any request, which makes scaling and recovery easy; the price is resending context, such as tokens, on every call.
- Cacheable. Responses are labelled cacheable or not, so clients and intermediaries can reuse them, at the risk of staleness.
- Uniform interface. Resources named by URLs, changed by sending representations (usually JSON), self-describing messages (method, media type, cache headers), and links in responses that drive the client’s next step.
- Layered system. A client can’t tell the origin from a proxy, gateway or CDN, so layers can be added freely, each costing a little latency.
- Code on demand (optional). The server may send scripts to extend the client.
Fielding was explicit about the price of the uniform interface: data moves in a standard form rather than one shaped for each application, so it is less efficient for any particular client. That is exactly the over-fetching problem GraphQL was built to remove.
What “RESTful” means in practice
In daily use, “REST API” means something looser: nouns in URLs, HTTP methods as verbs, JSON bodies, meaningful status codes, no server session. For orders:
GET /v1/orders/9876 → 200 OK with the order
GET /v1/orders?customer=c_51&status=ACTIVE
POST /v1/orders → 201 Created, Location: /v1/orders/9877
PATCH /v1/orders/9876 → 200 OK (change the delivery note)
DELETE /v1/orders/9876 → 204 No Content (cancel)
The methods carry meanings every proxy and library understands. RFC 9110 defines GET, HEAD, OPTIONS and TRACE as safe (read-only), and PUT, DELETE and the safe methods as idempotent (sending twice equals sending once). So a gateway may retry a timed-out GET on its own, but a POST needs an idempotency key.
The contract is usually an OpenAPI document, a language-agnostic description of paths, parameters, schemas and errors that tools turn into docs, client SDKs, server stubs and tests. REST doesn’t require one; without it, the contract lives in a wiki and drifts.
What you gain: HTTP’s machinery for free. A GET with Cache-Control: max-age=60 and an ETag can be served by the browser, a proxy or a CDN without touching your servers. curl is a debugger, and every partner already knows the model.
What you pay: round trips and shape. The server shapes every response, so clients over-fetch (the whole restaurant when the screen shows its name) and under-fetch (the order holds only restaurant_id, so another call follows). Nested views become sequential round trips, painful on mobile; endpoints multiply per client; and breaking changes need versions such as /v1 and /v2, run side by side for years.
GitHub hit exactly this. When it added a GraphQL API in 2016, its REST API caused over 60% of the requests reaching its database tier, a complete view could take two or three calls, and responses sent too much data yet missed what clients needed.
GraphQL: one endpoint, client-shaped queries
GraphQL, built at Facebook and open-sourced in 2015, is a query language plus a runtime. The server publishes a schema, a typed description of every object, field and relationship. Clients send a document naming the fields they want, usually as a POST to one URL such as /graphql, and the response mirrors the query’s shape.
type Order {
id: ID!
status: OrderStatus!
etaMinutes: Int
items: [OrderItem!]!
restaurant: Restaurant!
deliveryPartner: DeliveryPartner # null until a rider is assigned
}
type OrderItem { name: String! quantity: Int! }
type Restaurant { id: ID! name: String! area: String! rating: Float }
type DeliveryPartner { id: ID! firstName: String! maskedPhone: String! }
enum OrderStatus { PLACED PREPARING OUT_FOR_DELIVERY DELIVERED CANCELLED }
type Query { order(id: ID!): Order }
type Mutation { cancelOrder(id: ID!, reason: String): Order }
type Subscription { orderStatusChanged(id: ID!): Order }
The specification defines three operations: a query is a read-only fetch; a mutation is a write followed by a fetch, so cancelOrder returns the updated order in the same round trip; a subscription is a long-lived request that pushes data as events happen, usually over WebSockets or server-sent events.
Resolvers do the real work
Every field has a resolver, a function that produces its value. Executing a query walks the tree: Query.order calls the Orders service, Order.restaurant calls Restaurants, Order.deliveryPartner calls Delivery. The schema is the contract, and resolvers map it onto the services behind it.
Errors arrive inside a 200
A response holds data and, if anything failed, an errors list. Failures are often partial: if Delivery is down, deliveryPartner comes back null with an error whose path names that field, and the rest of the screen renders. That is why fields are nullable by default.
HTTP has no status for “partly worked”, so the GraphQL-over-HTTP guidance says to answer 2xx whenever data is present; only requests that fail before execution, such as validation errors, get a 4xx. GitHub returns 200 even when you exceed its primary rate limit.
Evolving without versions
Clients receive only the fields they name, so adding fields breaks nobody. To remove one, mark it @deprecated(reason: "use etaWindow"), wait until no operation asks for it, then delete it; the GraphQL project recommends this versionless evolution. The price: tracking field usage per client, and old app builds keep deprecated fields alive.
A 200 doesn’t mean it worked
GraphQL and gRPC both break alerting on HTTP 5xx: a GraphQL error usually rides inside a 200, and a gRPC error is a grpc-status trailer on an HTTP 200 stream. Count errors from the errors list per operation and from gRPC status codes per method, or your dashboard stays green through an outage.
GraphQL in production: N+1, cost limits and caching
The N+1 problem and DataLoader
Resolvers are simple because each handles one field of one object, and that is the trap. Take an order-history screen:
query History {
me {
orders(first: 20) { id status restaurant { name } }
}
}
orders makes one call and returns 20 orders; then restaurant resolves once per order, often for the same few restaurants. That is 1 + 20 = 21 calls for one screen, and every nested list multiplies it. The SQL version of this disease is the N+1 query problem.
The standard fix is batching, popularised by Facebook’s DataLoader. Resolvers call restaurantLoader.load(42) instead of the service. The loader waits for the current tick of the event loop to end, gathers every key requested in it, drops duplicates and calls your batch function once, which must return values in key order. Twenty orders from 12 restaurants become 1 + 1 = 2 calls.
The loader also memoises within a request, so restaurant 42 is fetched once however often it is asked for.
One loader per request
Create loaders per request. Their cache is a per-request memo, not a shared cache like Redis; a loader shared across requests can leak one user’s data into another’s response.
Depth, breadth and cost limits
A REST endpoint does bounded work; a GraphQL query does whatever the client writes. So servers cap depth and breadth (fields, aliases, batched operations), require pagination arguments on lists, and estimate cost before running a query.
GitHub documents its limits in detail:
- Every connection needs
firstorlastbetween 1 and 100, and one call may request at most 500,000 nodes. - A query’s point cost is the number of backend requests needed to fill every connection, assuming full lists, divided by 100 and rounded (minimum 1).
- Users get 5,000 points per hour; a secondary limit allows 2,000 points per minute, and requests running over 10 seconds are cut off.
Apply them to a dashboard asking for restaurants(first: 100) { orders(first: 100) { items(first: 50) { name } } }:
- Requests: 1 + 100 (orders per restaurant) + 100 × 100 (items per order) = 10,101, so about 101 points.
- Nodes: 100 + 10,000 + (100 × 100 × 50 = 500,000) = 510,100, over the cap, so it is rejected before running. With
items(first: 40)it fits: 100 + 10,000 + 400,000 = 410,100.
A request counter would call this “one request”, which is why rate limiting GraphQL is done in points.
Caching is harder, not impossible
GraphQL reads are usually POSTs to one URL, and RFC 9110 notes that most HTTP caches store only GET and HEAD responses, so a CDN has nothing to reuse. Teams win caching back in three layers:
- Client cache. Clients normalise responses by type and id (
Restaurant:42), so the next screen showing restaurant 42 renders from memory. - Persisted queries. The client sends the query’s SHA-256 hash instead of its text. With Apollo’s automatic persisted queries, an unknown hash returns
PERSISTED_QUERY_NOT_FOUNDand the client resends once with the full text. A hash fits in a URL, so queries can go out as GET and a CDN can cache them. - Server-side caches behind resolvers, as with any API.
A stricter variant, trusted documents, runs only operations registered at build time. It suits first-party apps, not public APIs.
gRPC: typed procedures over HTTP/2
gRPC is a remote procedure call framework that Google open-sourced (1.0 shipped in August 2016). You describe services and messages once in a .proto file, and the compiler generates client stubs and server interfaces for each language, so a remote call looks like a method call on a local, typed object.
syntax = "proto3";
package delivery.v1;
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order); // unary
}
service TrackingService {
rpc TrackPartner(TrackRequest) returns (stream Location); // server streaming
rpc UploadTrail(stream GpsPoint) returns (TrailSummary); // client streaming
rpc DispatchChannel(stream DispatchEvent) returns (stream DispatchEvent); // bidirectional
}
message GetOrderRequest { int64 order_id = 1; }
message Order {
int64 order_id = 1;
OrderStatus status = 2;
int64 total_paise = 3;
int64 restaurant_id = 4;
repeated OrderItem items = 5;
}
// OrderStatus, OrderItem, Location and the other messages are omitted
What travels on the wire
Each call is an HTTP/2 request to a path such as /delivery.v1.OrderService/GetOrder, with content-type: application/grpc+proto and, if a deadline is set, a grpc-timeout header such as 750m (750 ms). Each message gets a 5-byte prefix (compression flag plus 4-byte length). The outcome is a grpc-status code in HTTP/2 trailers after the last message, so a stream can deliver 1,000 messages and still end in an error (see the protocol description).
Protobuf is compact because its wire format carries field numbers instead of names and writes integers as “varints”, where small numbers take fewer bytes. Encode order_id = 9876, status = OUT_FOR_DELIVERY (enum value 4) and total_paise = 48900:
- JSON:
{"order_id":9876,"status":"OUT_FOR_DELIVERY","total_paise":48900}is 65 bytes. - Protobuf: a 1-byte tag plus 2-byte varint (3 bytes), a tag plus 1 byte (2), a tag plus 3-byte varint (4): 9 bytes, about 7× smaller, and parsing reads numbers instead of scanning text.
The gap shrinks for string-heavy messages and gzipped JSON. The price is opacity: a captured payload is unreadable without its .proto file. Field-numbering rules live in serialization and schema evolution.
Four call types
HTTP/2 gives each call its own stream, so gRPC offers four shapes, preserving message order within each call.
In the delivery app, TrackPartner streams the rider’s location every few seconds from one request, UploadTrail sends a batch of GPS points for one summary, and DispatchChannel offers orders to a rider’s device and hears accepts and declines on the same call.
Deadlines, cancellation and status codes
gRPC has a real error vocabulary: 17 status codes, from OK (0) through INVALID_ARGUMENT, NOT_FOUND and RESOURCE_EXHAUSTED (quota ran out) to UNAVAILABLE, a transient failure worth retrying with backoff, though not blindly for non-idempotent calls.
A deadline is the moment the caller stops waiting. gRPC sets none by default, so always set one. When it passes, the client gets DEADLINE_EXCEEDED and the server’s side is cancelled. Java and Go forward an incoming deadline to outgoing calls automatically (C++ needs it switched on). Two sharp edges: cancellation doesn’t roll back work already done, and DEADLINE_EXCEEDED can arrive after the server completed a write, so writes must be idempotent.
Where gRPC hurts
- Browsers. No browser API exposes raw HTTP/2 frames or trailers, so pages can’t speak native gRPC. gRPC-Web adds a translating proxy (commonly Envoy), and its official client supports unary and server-streaming calls only.
- Load balancing. A client multiplexes many calls over one long-lived HTTP/2 connection, so a layer-4 balancer, which spreads connections, pins all of them to one backend. Balance per call at layer 7 or in the client (see load balancing: L4 vs L7).
- Reach. Outside partners expect HTTP and JSON they can read.
Dropbox’s migration shows why teams accept those costs internally: hundreds of services in several languages exchanging millions of requests per second. It chose gRPC mainly to keep its existing protobuf messages, liked HTTP/2 multiplexing and bidirectional streaming, and lets most services call each other directly, with no proxy, to keep latency down.
One screen, three ways
The worked example: a food delivery app’s order screen shows status and ETA, the items, the restaurant’s name and area, and the rider’s first name and masked phone. The data lives in three services: Orders, Restaurants and Delivery. Times are illustrative: about 100 ms per round trip on 4G, about 1 ms per data-centre hop.
REST: two trips, or a custom endpoint
GET /v1/orders/9876
→ 200 OK
{ "id": 9876, "status": "OUT_FOR_DELIVERY", "eta_minutes": 12,
"restaurant_id": 42, "partner_id": 77,
"items": [{ "name": "Masala dosa", "qty": 2 }, { "name": "Filter coffee", "qty": 1 }],
"payment": { … }, "address": { … }, "invoice_url": "…", … }
# only now do we know which restaurant and rider to ask for; these two run in parallel
GET /v1/restaurants/42 → name and area, plus hours, cuisines, ratings, licence …
GET /v1/delivery-partners/77 → name and phone, plus vehicle, shift, payout details …
The second pair needs ids from the first, so the phone waits about 2 × 100 ms = 200 ms in network time alone; HTTP/2 can run the last two in parallel but can’t remove the dependency. Each reply is a whole resource, mostly unused here. The usual REST fixes are ?expand=restaurant,partner, sparse fieldsets (?fields=name,area), or a screen endpoint such as GET /v1/order-screens/9876, which is really a BFF.
GraphQL: one query
query OrderScreen($id: ID!) {
order(id: $id) {
status
etaMinutes
items { name quantity }
restaurant { name area }
deliveryPartner { firstName maskedPhone }
}
}
{
"data": {
"order": {
"status": "OUT_FOR_DELIVERY",
"etaMinutes": 12,
"items": [{ "name": "Masala dosa", "quantity": 2 }, { "name": "Filter coffee", "quantity": 1 }],
"restaurant": { "name": "Udupi Corner", "area": "Indiranagar" },
"deliveryPartner": { "firstName": "Ravi", "maskedPhone": "+91 98xxxxxx21" }
}
}
}
One round trip, about 100 ms plus a few milliseconds of fan-out inside the data centre, carrying exactly the eight leaf fields the screen renders. Showing the restaurant’s rating later is a change to the app’s query, with no server deploy.
gRPC behind a BFF
The phone doesn’t speak gRPC. A backend-for-frontend owned by the app team exposes one JSON route per screen and makes typed gRPC calls inside:
func (s *BFF) OrderScreen(ctx context.Context, orderID int64) (*Screen, error) {
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond) // budget for the whole screen
defer cancel()
order, err := s.orders.GetOrder(ctx, &orderpb.GetOrderRequest{OrderId: orderID})
if err != nil {
return nil, err // codes.NotFound becomes HTTP 404 for the app
}
var rest *restpb.Restaurant
var rider *deliverypb.Assignment
g, gctx := errgroup.WithContext(ctx) // the two lookups run in parallel
g.Go(func() (err error) {
rest, err = s.restaurants.GetRestaurant(gctx, &restpb.GetRestaurantRequest{Id: order.RestaurantId})
return err
})
g.Go(func() (err error) {
rider, err = s.delivery.GetAssignment(gctx, &deliverypb.GetAssignmentRequest{OrderId: orderID})
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
return buildScreen(order, rest, rider), nil
}
The 800 ms budget on ctx travels with every gRPC call as grpc-timeout. A production BFF would degrade, not fail, when only Delivery is down. The three approaches for this screen:
| Plain REST | GraphQL | BFF + gRPC | |
|---|---|---|---|
| Phone round trips | 2 sequential (3 requests) | 1 | 1 |
| Who shapes the response | Each resource’s owner | The client, per query | The app team, per screen |
| Unused fields sent | Most of each resource | None | None |
| Cacheable at a CDN | Yes, per resource URL | Only as persisted GET queries | Yes, if the screen route is a GET |
Side by side
| REST | GraphQL | gRPC | |
|---|---|---|---|
| Core idea | Resources (nouns) at URLs, HTTP methods as verbs | One typed graph; the client selects fields | Typed procedures (verbs) on services |
| Transport | Any HTTP version | HTTP, usually POST to one endpoint; WebSockets or SSE for subscriptions | HTTP/2 only; browsers need gRPC-Web and a proxy |
| Payload | Usually JSON; any media type | JSON | Protobuf binary (JSON mapping exists) |
| Contract | Optional: OpenAPI | Required: the schema, which clients can introspect | Required: .proto files, with generated code |
| Response shape | Server decides, per endpoint | Client decides, per query | Server decides, per method |
| HTTP caching | Native: GET, ETag, Cache-Control, CDN |
Hard: needs persisted queries sent as GET; clients cache by id | None at the HTTP layer; cache inside services |
| Streaming | Not built in; add SSE or WebSockets | Subscriptions | Native: server, client and bidirectional |
| Errors | HTTP status codes | 200 with an errors list and partial data |
17 status codes in trailers |
| Versioning | /v1, /v2 or a header |
Add fields, deprecate old ones, no versions | Add fields with new numbers; a new package for breaking changes |
| Debugging | curl, browser, readable logs |
Explorers built on introspection | Needs the .proto file or server reflection |
| Best fit | Public and partner APIs, simple CRUD, cacheable reads | Product UIs with many screens and several clients | Internal service-to-service calls, low latency, streaming |
Choosing a style
Start from who calls the API, not from fashion.
- Public and partner APIs: REST with OpenAPI. Partners use any language, sit behind proxies you don’t control, expect
curlto work, benefit from HTTP caching, and upgrade on their own schedule, so explicit versions suit them. - Product UIs with many screens and clients: GraphQL or a BFF. When Android, iOS and web each want different slices of the same data and ship weekly, client-shaped queries remove most “please add this field” tickets. Large organisations stitch many teams’ schemas into one graph with federation (see API composition, BFF and GraphQL federation); a small team with five screens may be happier with a REST BFF.
- Internal service-to-service calls: gRPC. Many languages, high call rates, strict contracts, streaming and deadlines. The more services you run (see monolith vs microservices), the more typed contracts pay off.
- Telling someone something happened: neither. Use events and webhooks (see “Going deeper”).
In the interview
Name the boundary, not your favourite tool: “Partners get versioned REST with OpenAPI. Apps go through a GraphQL layer, one round trip per screen. Behind it, services talk gRPC with deadlines, and partners hear about events through webhooks.”
Going deeper
GraphQL’s attack surface
A GraphQL endpoint runs small programs, not fixed requests, which gives attackers more to work with.
- Introspection. Anyone can ask the schema to describe itself: a map of every type and field. First-party APIs often disable it in production, but the GraphQL security guide calls that obscurity, not protection, since error suggestions (“did you mean …?”) leak names too. Real defences are trusted documents and field-level authorisation inside resolvers.
- Deep and cyclic queries. If orders link to restaurants and restaurants to orders, a client can nest
order → restaurant → orders → restaurant → …until the server falls over. Cap depth, and list nesting tighter. - Aliases. One HTTP request can ask for the same expensive field a thousand times under different names:
query {
a1: order(id: "9001") { restaurant { name } }
a2: order(id: "9002") { restaurant { name } }
# … a3 to a1000
}
A request counter sees one request; the server does 1,000 lookups. Aimed at an OTP mutation, it becomes 1,000 guesses per request. Limit aliases and batched operations, and charge by cost.
Deadlines across hops
With deadline propagation, the first caller sets one budget, every hop forwards the time left, and when it runs out every hop stops together.
The arithmetic is subtraction. The BFF spends 50 ms, so Orders receives 800 − 50 = 750 ms; Orders spends 150 ms, so Pricing receives 750 − 150 = 600 ms. Sending time left rather than a wall-clock time sidesteps clock skew.
Dropbox reported that forcing engineers to declare deadlines in service definitions fixed “whole classes” of reliability problems; it even writes the deadline into SQL queries as a comment so a proxy can kill queries nobody is waiting for. Two rules: set the budget from the user’s patience at the edge and let it shrink, instead of giving each hop its own generous timeout; and fit retries inside what remains, or one slow dependency becomes a retry storm. More in timeouts, retries and backoff.
HATEOAS: the constraint almost nobody follows
Hypermedia as the engine of application state says a client needs only an entry URL and its media types; every next step comes from links in responses. In a 2008 post Fielding said bluntly that an HTTP API not driven by hypertext is RPC, not REST.
By that standard, almost every “REST API” is RPC with nice URLs, which is usually fine: real clients are coded against documented URLs. GitHub, which did build hypermedia into its REST API, found the *_url hints bloated responses, and following links meant repeated round trips. What survives is small: a Location header after a create, and next-page links for pagination.
The fourth shape: webhooks and events
All three styles are request-response: the client asks. Integrations often need the opposite, “tell me when the order is delivered”, and polling wastes calls; GitHub’s rate-limit guide tells integrators to use webhooks instead. The answers:
- Webhooks for partners: POST a signed event to a URL they registered, and retry on failure (webhooks).
- Events on a log or queue between your services, so producers don’t wait for consumers (event-driven architecture).
- Live connections for users: GraphQL subscriptions, gRPC server streams, or SSE and WebSockets.
Cheat sheet
| Topic | Remember it as |
|---|---|
| REST | Resources at URLs plus HTTP’s methods, status codes and caching; contract in OpenAPI |
| Fielding’s constraints | Client-server, stateless, cacheable, uniform interface, layered, optional code on demand |
| REST’s cost | Server-shaped responses: over-fetching, under-fetching, sequential round trips, /v2 |
| GraphQL | One endpoint, typed schema, client-shaped queries; query, mutation, subscription |
| GraphQL errors | 200 with data plus an errors list; failed fields become null |
| N+1 fix | DataLoader: collect keys for one tick, dedupe, one batch call; one loader per request |
| GraphQL limits | Depth, aliases, required pagination, cost points (GitHub: 5,000 points/hour, 500,000 nodes) |
| GraphQL caching | POST defeats CDNs; persisted query hashes sent as GET bring them back |
| gRPC | Protobuf contract, generated stubs, HTTP/2, binary frames, status in trailers |
| Deadlines | No default; set at the edge, forwarded as time left, every hop cancels together |
| gRPC caveats | Browsers need gRPC-Web plus a proxy; balance per call at L7, not per connection |
| Default picks | Partners: REST. Apps: GraphQL or a BFF. Internal: gRPC. Push: webhooks and events |
How to say it in the interview
“I choose per audience. Public and partner APIs are REST with OpenAPI: every client speaks HTTP, GETs cache at the CDN, and explicit versions suit outside developers. Our apps talk to a GraphQL layer, so each screen is one round trip with only the fields it shows; resolvers batch with DataLoader and queries are charged by cost. Behind that, services use gRPC: protobuf contracts, generated clients, streaming where needed, and an edge deadline every hop forwards. Partners hear about changes through webhooks. Three styles in one system is normal.”
Why not use GraphQL for everything, including service-to-service calls? Internally the caller is code you control, so flexible queries buy little and cost parsing and cost analysis on every hop; gRPC gives tighter contracts, smaller messages, streaming and deadlines. GraphQL earns its keep at the edge, where many clients want many shapes.
How would you cache a GraphQL API? Client caches keyed by type and id, persisted queries sent as GET so a CDN can cache them, and ordinary caches behind resolvers.
What is the N+1 problem in GraphQL, and how do you fix it? A list returns N items and a child field resolves once per item: 1 + N backend calls. A per-request DataLoader batches them, so 20 orders become 2 calls instead of 21.
Why is gRPC faster than REST with JSON, and when isn’t it? Smaller binary messages (9 bytes against 65 for our order summary), no text parsing, and many calls multiplexed on one HTTP/2 connection. The edge shrinks for string-heavy or gzipped payloads and vanishes when the time goes to a slow database query, so measure first.
How does each one handle breaking changes?
REST runs /v1 and /v2 side by side. GraphQL adds fields, deprecates old ones and removes them at zero usage. gRPC adds fields with new numbers, never reuses one, and puts breaking changes in a new package such as delivery.v2.
Where to go next
- What is an API: contracts, clients and servers.
- API design for system design interviews: sketching endpoints in the round.
- Serialization and schema evolution: JSON, protobuf and safe schema changes.
- API composition, BFF and GraphQL federation: one client-facing layer over many services.
- HTTP and HTTPS: methods, caching headers and HTTP/2, which all three build on.
- Load balancing: L4 vs L7: why long-lived gRPC connections need per-call balancing.
- Real-time connections: SSE and WebSockets, behind subscriptions.
- Monolith vs microservices: when internal gRPC starts to pay off.
References and further reading
- Architectural Styles and the Design of Network-based Software Architectures, Chapter 5: REST (Fielding, 2000)ics.uci.eduThe REST constraints and what each one buys and costs, including the efficiency price of a uniform interface.
- RFC 9110: HTTP Semanticsrfc-editor.orgSafe and idempotent methods, and the note that most caches only store GET and HEAD responses.
- GraphQL Specification (September 2025 edition)spec.graphql.orgThe three operation types, the response format with data and errors, and how execution errors become nulls.
- GraphQL Learn: Performancegraphql.orgThe N+1 problem, batching with DataLoader, GET requests for queries and hashed persisted queries for CDN caching.
- GraphQL Learn: Securitygraphql.orgTrusted documents, depth, breadth and alias limits, query complexity analysis and the limits of disabling introspection.
- GitHub Docs: Rate limits and query limits for the GraphQL APIdocs.github.comPoint-based primary limits, the 500,000-node cap, first/last between 1 and 100, secondary limits and the 10-second timeout.
- The GitHub GraphQL API (GitHub Blog, 2016)github.blogWhy GitHub added GraphQL: REST drove over 60% of database-tier requests, and views needed two or three calls.
- gRPC: Core concepts, architecture and lifecyclegrpc.ioProtobuf service definitions, generated stubs, the four RPC types, per-call ordering, metadata and cancellation.
- gRPC: Deadlinesgrpc.ioNo default deadline, server-side cancellation, and deadline propagation converted to a timeout to avoid clock skew.
- Courier: Dropbox migration to gRPC (Dropbox Tech Blog)dropbox.techA real adoption: why Dropbox chose gRPC, direct service-to-service calls, and mandatory deadlines that travel across boundaries.