API Design: How to Choose REST, GraphQL, or gRPC
Choosing the wrong API style makes late-stage changes impossible. This article clarifies the philosophies and fit of REST (resources/universal/simple), GraphQL (flexible fetching/frontend-driven), and gRPC (high performance/internal microservices), plus scenario-based selection and REST's own pitfalls (versioning/status codes/nesting).
Key Points
- Topic Focus: TypeScript、DevOps
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Online discussions about API style almost always devolve into "REST vs GraphQL vs gRPC, which is strongest" tribalism. This kind of discussion is meaningless. These three styles are not three answers to the same question; they are three philosophies designed for different scenarios—choosing the wrong one doesn't cost "a bit of performance", it means late-stage immutability: you can't add fields, clients break, and internal service calls get an extra layer.
This article isn't about who wins; it's about how to choose by scenario, and the pitfalls each style brings.
1. The philosophical differences: resources, queries, methods
To understand the differences between the three, first look at how each defines "what an API is".
- REST: Resource-oriented. The entire API is a set of "nouns" (resources), and you perform actions on these nouns with HTTP methods (GET/POST/PUT/DELETE). The essence is "transferring the state of a set of resources" (Representational State Transfer).
- GraphQL: Query-driven. The client writes a query describing "what I want", and the server returns it based on that query. The essence is "querying a Graph".
- gRPC: Method invocation. The essence is Remote Procedure Call (RPC); you call remote methods like calling local functions, with strongly typed messages for both input and output.
A comparison table first; details below:
| Dimension | REST | GraphQL | gRPC |
|---|---|---|---|
| Abstraction model | Resource (noun) | Query (Graph) | Method invocation |
| Protocol | HTTP/1.1 or HTTP/2 | HTTP (usually POST) | HTTP/2 |
| Data format | JSON (mainstream) | JSON | Protobuf (binary) |
| Data-fetching granularity | Fixed endpoints | Client on-demand | Fixed message fields |
| Browser friendly | Excellent | Good | Poor (needs gRPC-Web or gateway) |
| Caching | HTTP cache natively supported | Hard (POST has no cache semantics) | None |
| Typical scenarios | Public APIs, CRUD | Frontend aggregation, multi-variant UI | Internal microservices, low latency |
Remember this table and you basically remember where each of the three sits.
2. REST: mature, universal, but with the most pitfalls
REST's advantage is obvious: HTTP-native. It directly reuses HTTP's semantics—methods, status codes, headers, caching. This means CDNs, reverse proxies, browser caches, curl, and Postman all come for free. Public-facing APIs are almost REST by default, because the ecosystem is the thickest and the learning cost is the lowest.
The suitable scenarios are clear: public APIs, third-party integrations, CRUD-heavy business. An order system—create order, query order, change status, delete—written in REST looks as clean as a textbook.
But REST's pitfalls are real and many, and they're all "don't notice during design, discover during runtime" pitfalls:
- How to manage versions. Once online, changing a field is a breaking change. Common practice is to put the version in the URL (
/api/v1/orders), or use a header (Accept: application/vnd.myapi.v1+json). Both work; the key is to think it through from the start, don't wait until v3 to remember. - Status code semantics get abused. The most typical is HTTP 200 wrapping a
{ code: 500, msg: "failed" }. This completely hollows out the HTTP status code. The correct approach is to make HTTP status codes meaningful: 201 Created, 400 Bad Request, 404 Not Found, 422 Unprocessable Entity, 429 Too Many Requests; business error details go in the body. - Over-fetching / under-fetching. A list endpoint returns 30 fields, the frontend uses only 3—this is over-fetching; to see order details you have to call 5 more endpoints—this is under-fetching. This is the pain point REST gets criticized for most by GraphQL.
- How to design nested resources.
/users/123/ordersor/orders?userId=123? There's no standard answer, but nesting within two levels is a commonly acceptable upper limit; beyond that, use query parameters or independent endpoints.
3. GraphQL: flexibility is its strength, and also its burden
The core problem GraphQL solves is the "over/under-fetching" from the previous section. The frontend writes its own query, fetching exactly the fields it wants, and one request can bring back multiple resources together. Plus a strongly typed Schema lets the frontend get precise type definitions for autocompletion.
query {
user(id: "123") {
name
orders(last: 3) {
total
items { name price }
}
}
}
One request fetches three resources—user, orders, order items—with fields as minimal as possible. This experience is a step-function improvement for frontend-variable, multi-source-aggregation scenarios (typical: e-commerce homepage, content platform, Dashboard).
But GraphQL's costs are equally real:
- N+1 problem. Querying a list of users, each user related to orders, a naive implementation will fire N order queries. You must use DataLoader for batch loading; this is additional cognitive burden.
- Complex query attacks. A malicious client can construct deeply nested queries to crash the server. You must do query depth limits and complexity scoring.
- Hard to cache. GraphQL uses POST, and POST has no HTTP cache semantics. You rely on client caching (Apollo Client, Relay) and server-side persisted queries to compensate.
- High server-side implementation cost. Schema design, resolver orchestration, permission granularity (every field needs authorization consideration)—much more complex than writing a REST controller.
Don't adopt GraphQL just to "save the frontend a few calls". If the number of endpoints is stable and frontend scenarios are single, REST is entirely sufficient; GraphQL is a burden instead.
4. gRPC: the performance king for internal services
gRPC takes a different path. It uses Protobuf for binary serialization (30%-70% smaller than JSON, parsing an order of magnitude faster), runs on HTTP/2 (multiplexing, header compression), and natively supports bidirectional streaming. The interface contract is defined through .proto files, generating multi-language clients.
service OrderService {
rpc CreateOrder(CreateOrderRequest) returns (Order);
rpc SubscribeOrders(SubscribeRequest) returns (stream OrderEvent);
}
This combination makes gRPC almost unbeatable in internal microservice communication, low-latency scenarios: service meshes, real-time data streams, high-frequency trading, internal RPC gateways. Microservices calling each other, latency compressed from tens of milliseconds to single-digit milliseconds—gRPC is key.
gRPC's streaming capability deserves special mention. Four modes: unary call, server streaming, client streaming, bidirectional streaming. Subscribing to real-time quotes, log reporting, chat room message push—these with REST either require polling or WebSocket, while gRPC's bidirectional streaming is natively supported and type-safe.
But gRPC isn't suitable for direct external use. Browsers are unfriendly to HTTP/2 trailers and Protobuf handling; usually you need gRPC-Web or a gRPC-Gateway to expose it as REST/JSON. Debugging is also harder than REST—you can't directly curl a binary body; usually you rely on reflection plus a command-line tool like grpcurl to get through.
5. Selection decision: don't take sides, look at the scenario
Converge the judgments above into one sentence: look at who this API faces.
- Facing external, third-party, browser → REST. Thick ecosystem, cacheable, easy to debug, the default choice for public APIs.
- Facing your own frontend, variable UI, needing multi-source aggregation → GraphQL. Frontend fetches on-demand, backend gets fewer ad-hoc requests.
- Facing internal inter-service communication → gRPC. Performance, streaming, strongly typed contracts.
In practice these three are often combined. A fairly common architecture is:
Browser ──REST/GraphQL──> BFF (Backend for Frontend) ──gRPC──> Internal microservices
Use REST or GraphQL externally for friendliness; use gRPC internally for performance. This isn't fence-sitting; it's layering: each layer uses the most appropriate tool.
6. REST best practices: get the details right
If you chose REST (the choice for most external APIs), get these details right and your API quality surpasses 80% of peers:
- Resources use plural nouns:
/ordersinstead of/getOrder,/createOrder. Leave actions to HTTP methods. - Version in the URL:
/api/v1/orders. Simple, explicit, easy to debug, more universal than header versioning. - Semantic status codes:
201creation success,204no content,400request format error,422business validation failure,401unauthenticated,403no permission,429rate limited,500server error. Don't return 200 for everything. - Pagination uses cursors, not offset: offset misaligns when data changes (missing or duplicate data), and on large tables it's catastrophically slow (
OFFSET 100000scans 100k rows). Cursor pagination is stable and fast, and naturally supports "incremental fetching". The return structure carriesnextCursor, which the frontend includes next time. - Filtering and sorting go through query parameters:
/orders?status=paid&sort=-createdAt. Hand expressiveness to query parameters, rather than designing RPC-style endpoints like/getPaidOrdersSortedByDate. - Unified error format: regardless of which endpoint errors, the return structure is consistent.
{
"error": {
"code": "ORDER_NOT_FOUND",
"message": "Order 123 does not exist",
"details": { "orderId": "123" }
}
}
7. API documentation isn't optional
Last point, often overlooked: an API without documentation equals not designed.
- REST uses OpenAPI (Swagger)—a
.yamldescribes each endpoint, parameter, response, generating interactive docs and client SDKs. - GraphQL's Schema is itself the documentation, paired with GraphiQL / Apollo Studio for automatic visualization.
- gRPC's
.protofile is the contract; generating multi-language stubs also generates documentation.
Documentation isn't written for the doc tool; it's written for the caller and yourself three months later. A runnable OpenAPI lets the frontend advance independently of the backend, lets SDKs auto-generate, and lets contract testing land.
Summary
- REST suits external use: HTTP-native, cache-friendly, thickest ecosystem; pitfalls in versioning, status code semantics, over/under-fetching.
- GraphQL suits frontend-variable scenarios: on-demand data fetching, strongly typed Schema, one-time multi-source aggregation; cost is N+1, complex query protection, hard caching.
- gRPC suits internal services: Protobuf + HTTP/2 + streaming, low latency, strong contracts; not suitable for direct external use, needs a Gateway.
- The three often combine: external REST/GraphQL, internal gRPC, BFF in between to bridge.
- Selection criterion is only one: look at who this API faces, not which style is more "advanced".
Key quote: Good API design isn't about which style you chose; it's about each layer using the most appropriate tool, and treating contracts (docs/Schema/proto) as first-class citizens—styles can be debated, contracts cannot be skipped.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.
Article
What Value Do Server Components Offer Content Sites?
Technical trends and engineering practices around Next.js and React, from an independent developer's perspective.