The same domain has to be exposed to a mobile app, a partner integration and internal service-to-service traffic. Where does GraphQL fit, where does gRPC, and where does neither?
Choose per consumer: GraphQL where many client shapes make over-fetching the problem, gRPC for internal traffic wanting typed generated stubs and streaming, plain JSON over HTTP for partners who want curl and a CDN. Then batch your resolvers, because the N+1 is structural.
What the interviewer is scoring
- Does the candidate choose per consumer instead of arguing for one protocol across the estate
- Whether schema evolution is designed for, with additive change and deprecation rather than versioned endpoints
- That the N+1 is explained as a consequence of per-field resolution, not as a slow query
- Can they scope a batching cache correctly, given that a shared one leaks data across users
- Whether the operational cost of long-lived streams is raised, including load balancing and deploys
Answer
Match the protocol to the consumer, not to the estate
The wrong answer is a single protocol for everything, and the wrong reason is consistency. These three consumers have genuinely different constraints, and a design that acknowledges that will outperform one that standardises.
The mobile app is the hard case, and it is the case GraphQL was built for. You do not control when a user updates the app, so an old client will be talking to your servers for years, and every screen wants a slightly different slice of the same objects. Under REST that becomes either chatty round trips on a high-latency network, endpoints that return far more than any screen needs, or a proliferation of ?include= parameters and bespoke endpoints per screen. GraphQL lets the client state the shape it wants, which removes both the over-fetching and the endpoint sprawl, and adding a field for a new screen requires no server release coordination at all.
Internal service-to-service traffic wants none of that flexibility and a lot of what gRPC provides. The consumers are few, known, and deployed by you, so the value is in a machine-checked contract, generated client stubs in every language you run, compact binary framing on the wire, multiplexing over one HTTP/2 connection, and deadlines that propagate through a call chain so a caller's timeout budget is visible to everything downstream. The last of those is underrated and worth naming: it is the difference between a slow dependency being contained and it consuming thread pools three services away.
The partner integration wants the boring option. A partner's platform team wants to read a page of documentation, run a curl, and be finished; they do not want to install a protobuf toolchain or learn your graph. Plain JSON over HTTP with correct status codes and cache headers costs them nothing, works through their corporate proxy, and can be cached by a CDN. This is where "neither" is the right answer, and saying so demonstrates judgement rather than preference.
The obvious follow-through is that these are transports over one domain layer, not three implementations. The business logic lives in services that none of the three protocols knows about, and each interface is a thin adapter. Get that wrong and you have three divergent behaviours for the same operation.
Designing a schema you can change
Both technologies replace versioning with evolution, and both have rules that must be followed for that to work.
In GraphQL there is no endpoint to version, so change must be additive: add fields and types freely, mark what you are removing @deprecated with a reason, watch the field-usage metrics until traffic reaches zero, then delete. That last part requires you to instrument field-level usage, which people forget until they need to remove something and have no idea who is asking for it.
Nullability is the design decision with the most consequences and the one candidates most often get backwards. In GraphQL, if a non-nullable field resolves to an error, the error propagates up to the nearest nullable parent, and that parent becomes null. Mark a deep field non-null and one failing downstream call can blank an entire branch of the response, including data that resolved perfectly. Being liberal with non-null looks like stronger typing and behaves like a much larger failure blast radius, so reserve it for fields that genuinely cannot be absent.
Beyond that, design the graph around client use cases rather than mirroring your tables — a schema that is a one-to-one projection of the database recreates the coupling GraphQL exists to remove. Use cursor-based connections for lists so pagination is stable under insertion. And return mutation errors as data in a payload type, with a typed result union or an errors field, rather than throwing: a validation failure is an expected outcome of a mutation, and expressing it in the top-level errors array makes it impossible for a typed client to handle properly.
Protobuf has stricter and simpler rules. A field's number is its identity on the wire; the name is a source-level convenience. So renaming a field is wire-compatible and renumbering it is not, and reusing the number of a deleted field is the classic corruption bug — an old client's bytes get decoded into an unrelated new field. Mark the number reserved when you delete it. Numbers 1 to 15 encode their tag in a single byte, so spend them on fields present in every message. Give every enum a zero value meaning unspecified, because zero is what an absent enum decodes to and you cannot otherwise distinguish "not set" from your first real value.
The N+1 is a shape problem, not a database problem
A GraphQL resolver runs per field per object. So this query:
{
orders(last: 50) {
nodes { id customer { name tier } }
}
}
executes one query for the fifty orders, then invokes the customer resolver fifty times, each fetching one row by id. Fifty-one round trips where a join would have been one. Nothing in the query looks expensive, the client did nothing wrong, and every individual query is fast — which is why this shows up as a mysterious latency floor rather than a slow query in your logs. It is not caused by an unindexed column and it will not be fixed by a faster database; it is caused by the execution model resolving fields independently.
The fix is batching. A dataloader sits between resolvers and the data source, collects the keys requested during one tick of the event loop, and dispatches them as a single call.
// One loader instance PER REQUEST. A module-level loader would share
// its cache between users, which is a data leak, not an optimisation.
const customerLoader = new DataLoader(async (ids) => {
const rows = await db.customers.findMany({ where: { id: { in: ids } } });
// Must return results in the same order as `ids`, with a slot per key.
const byId = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => byId.get(id) ?? null);
});
Two details are where implementations go wrong. The loader must return one result per requested key in the requested order, including nulls, or results get silently attributed to the wrong parent. And the loader — with its cache — must be constructed per request. A loader hoisted to module scope for efficiency caches one user's customer records and serves them to the next request, which is an authorisation bypass dressed as a performance improvement. That is the specific thing separating an answer that has shipped this from one that has read about it.
Batching bounds the round trips but not the cost. Because a client composes its own query, the worst case is defined by your schema's shape rather than by anything you wrote, and a nested query over connected types can be enormously expensive with no obvious size. So a public graph needs a complexity budget computed before execution, a depth limit, mandatory page-size caps on every list, and ideally persisted queries — where clients register operations ahead of time and send an identifier — which caps the query space to what you have reviewed and, as a bonus, lets you serve them over GET so a CDN can cache them. This is also the honest cost of GraphQL to state up front: one opaque POST endpoint discards HTTP caching, and you rebuild it deliberately or not at all.
Streaming, and what it is not
gRPC has four call shapes: unary, server-streaming for one request and many responses, client-streaming for many requests and one response, and bidirectional for both. Server-streaming suits a live feed or a large result set you would rather not materialise; client-streaming suits an upload chunked into messages; bidirectional suits a genuine conversation. HTTP/2 flow control gives you backpressure for free, which is the main technical advantage over rolling your own chunked protocol.
The reason to be cautious is operational rather than technical. A stream is a long-lived stateful connection, and connections are what infrastructure assumes are short. A layer-4 load balancer pins a gRPC connection to one backend for its lifetime, so a new instance receives no traffic until clients reconnect and your load distribution goes stale — the answers are client-side load balancing or a proxy that balances per request rather than per connection. A rolling deploy severs every stream on a draining instance, so the client must reconnect and resume, which means the protocol needs a resume token. Idle streams die silently in NAT devices unless you configure keepalives.
And the framing to close on: a stream is not a queue. There is no durability, no replay, no consumer group and no delivery guarantee once the socket is gone. If a missed message matters, you want a broker, and streaming RPC is for the case where the freshest value matters more than the complete history.
Likely follow-ups
- How do you stop a single GraphQL query from costing you a minute of database time?
- Two teams own parts of one graph. How do you compose it without one team's deploy blocking the other's?
- What breaks if a protobuf field number is reused after a field is deleted?
- When would you use bidirectional streaming rather than a message broker?
Related questions
- Clients are asking for page 4,000 of your /orders collection. How is that endpoint paginated, and what would you change?mediumAlso on api-design4 min
- Service A needs something from service B. When should that be a synchronous call and when should it be an event?mediumAlso on api-design3 min
- Design the account-information API a third party will call on behalf of your customers. Where does consent live, and what does it constrain?hardAlso on api-design6 min
- Design the contract for a public API. How do you handle pagination, idempotency and versioning?hardAlso on api-design6 min
- One of your endpoints takes four minutes to complete. How should the API expose it?hardAlso on api-design6 min
- Return the k most frequent elements from a large stream of values. Why not just sort by frequency?mediumAlso on streaming4 min
- How does your order placement change on a venue that allocates pro rata within a price level instead of first in, first out?hardSame kind of round: concept6 min
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardSame kind of round: concept7 min