REST: Constraints, Not a Standard
REST is not a protocol or a specification. It is an architectural style described by Roy Fielding in his 2000 dissertation. A REST API is one that satisfies six constraints: client-server separation, statelessness, cacheability, uniform interface, layered system, and optional code on demand. Most APIs called "RESTful" satisfy only the first three and ignore the rest, particularly HATEOAS (Hypermedia As The Engine Of Application State).
In practice, REST means resources identified by URLs, manipulated via HTTP verbs, with representations in JSON (or XML). The protocol does the heavy lifting: caching via ETags and Cache-Control, content negotiation via Accept headers, authentication via Authorization headers. The tradeoff is that the client drives everything. The server cannot push updates; the client must poll.
HTTP verbs and their contracts
| Method | Safe | Idempotent | Body | Use |
|---|---|---|---|---|
| GET | yes | yes | no | Fetch a resource or collection |
| POST | no | no | yes | Create a new resource |
| PUT | no | yes | yes | Replace a resource entirely |
| PATCH | no | no | yes | Partially update a resource |
| DELETE | no | yes | no | Remove a resource |
Safe means the operation has no side effects.Idempotent means calling it N times produces the same result as calling it once. Safe implies idempotent. POST is neither. PUT and DELETE are idempotent but not safe.
Status codes worth knowing
URL design principles
Good vs bad URL patterns
Avoid
/getUser?id=42 /api/createPost /users/getFollowers /deleteComment/5 /api/v1/user_list
Prefer
GET /users/42 POST /posts GET /users/42/followers DELETE /comments/5 GET /users
Nouns, not verbs. Plural collection names. Nesting max two levels deep. Actions via HTTP verbs.
API versioning strategies
| Strategy | Example | Pro | Con |
|---|---|---|---|
| URL segment | /api/v2/users | Explicit, cacheable, easy to route | Duplicates URLs across versions |
| Query param | /api/users?version=2 | Backward-compatible default | Often forgotten, less cache-friendly |
| Header | Accept: application/vnd.api+json;version=2 | Clean URL, spec-compliant | Invisible in browser, harder to test |
| Content negotiation | Accept: application/vnd.company.user.v2+json | Fully REST-compliant (HATEOAS-friendly) | Complex to implement and document |
URL segment versioning (/v2/) is the industry default and for good reason. It is explicit, trivially cacheable by CDNs and proxies, easy to route at the load balancer, and visible in logs. Start here unless you have a specific reason not to.
The over-fetching problem
REST couples resource shape to endpoint. A GET /users/42 returns the entire user object whether the client needs one field or all of them. A mobile app showing a user avatar and name burns bandwidth downloading the full profile. Worse, a page that needs user, post, and comment data must make three sequential requests before it can render.
Partial solutions exist: sparse fieldsets (?fields=id,name), compound documents (embed related resources in the response), and purpose-built endpoints (/api/post-card-data). All of these are workarounds. They either push the problem to the client (sparse fieldsets), over-engineer the API (compound documents), or erode the uniform interface (bespoke endpoints). GraphQL emerged specifically to solve this.
GraphQL: Query What You Need
GraphQL was developed at Facebook in 2012 and open-sourced in 2015. The motivation was direct: the News Feed required data from dozens of different backend services, and REST endpoints could not express the variable, nested, client-driven shape of that data efficiently. GraphQL moves the query language to the client side.
At the center is the schema. The server defines a type system: every object type, every field, every relationship between types. Clients write queries in the GraphQL query language that traverse this type graph. The server validates queries against the schema at parse time and returns exactly the fields requested, nothing more, nothing less.
3 HTTP requests required
GET /api/v1/posts/42 -> also needed: GET /api/v1/users/1 GET /api/v1/posts/42/comments?count=true Three round trips before the page can render.
Response, 5 fields needed, 10+ returned
{
"id": 42,
"title": "Building at Scale", ✓ needed
"excerpt": "A deep dive...", ✓ needed
"content": "Lorem ipsum...", ✗ 5 KB, not needed
"slug": "building-at-scale", ✗ not needed
"author_id": 1, ✗ need name, not id
"created_at": "2025-01-01T...",✗ not needed
"updated_at": "2025-01-15T...",✗ not needed
"view_count": 14200, ✗ not needed
"status": "published", ✗ not needed
"category_id": 3 ✗ not needed
}
+ separate responses for
/users/1 and /comments countThree operation types
query { user(id: 42) { name email } }Fetches data. Safe and idempotent. May be batched and cached.
mutation { createPost(title: "Hello") { id } }Modifies data. Executed serially (top-to-bottom) when multiple mutations are sent.
subscription { newComment(postId: 42) { text author { name } } }Long-lived connection. Server pushes events to the client when data changes.
Schema design: a minimal example
Schema definition language (SDL)
type Query {
post(id: ID!): Post
posts(first: Int = 20, after: String): PostConnection
user(id: ID!): User
}
type Mutation {
createPost(input: CreatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
type Post {
id: ID!
title: String!
excerpt: String
commentCount: Int!
author: User! # resolved separately, DataLoader target
tags: [Tag!]!
createdAt: DateTime!
}
type User {
id: ID!
name: String!
avatarUrl: String
}
type Tag { name: String! }
input CreatePostInput {
title: String!
excerpt: String
}
# Relay-style cursor pagination
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge { node: Post!; cursor: String! }
type PageInfo { hasNextPage: Boolean!; endCursor: String }The DataLoader pattern
The most common GraphQL performance bug is the N+1 problem. When you resolve a list of 100 posts and each post resolver independently fetches its author, you execute 101 queries. DataLoader solves this by collecting all keys within a single event loop tick and issuing a single batched query.
Without DataLoader
// posts resolver
posts.forEach(post => {
// N separate queries
db.query(
"SELECT * FROM users WHERE id = ?",
[post.authorId]
);
});
// 100 posts = 101 queriesWith DataLoader
const userLoader = new DataLoader(
async (ids) => {
const users = await db.query(
"SELECT * FROM users WHERE id = ANY(?)",
[ids]
);
return ids.map(id =>
users.find(u => u.id === id)
);
}
);
// 100 posts = 2 queries totalBenefits and costs
Caching is harder
REST GET requests are cacheable by URL. GraphQL typically uses POST for all queries, which HTTP caches ignore. You must implement application-level caching (persisted queries, CDN rules keyed on query hash).
N+1 on the server
Resolving author for 100 posts fires 100 SELECT queries unless you use DataLoader. DataLoader batches and deduplicates within a single tick: 100 author lookups become 1 SELECT WHERE id IN (...).
Schema is a contract
The GraphQL schema is explicit, introspectable, and versioned by default. Clients cannot request fields that do not exist. Tooling (GraphiQL, Apollo Studio) generates documentation automatically.
One endpoint, all operations
A single /graphql endpoint replaces dozens of REST endpoints. Routing is by operation name, not URL. This simplifies load balancing but makes per-endpoint rate limiting and monitoring harder.
Over-fetching eliminated
Clients specify exactly the fields they need. A mobile app requesting a user card gets name and avatarUrl. A desktop dashboard requesting a full profile gets all 30 fields. One schema, multiple shapes.
Arbitrary query depth
Without query depth limits or complexity scoring, a malicious client can craft deeply nested queries (user -> posts -> comments -> author -> posts ...) that exhaust the server. Protect with depth limits and query cost analysis.
gRPC: Protocol Buffers and HTTP/2
gRPC is a remote procedure call framework developed at Google and open-sourced in 2015. It uses Protocol Buffers as its interface definition language and serialization format, and HTTP/2 as its transport. The combination gives it properties that REST and GraphQL cannot match: strongly typed schemas enforced at compile time, binary serialization 3-10x smaller than JSON, multiplexed streams over a single TCP connection, and server push.
The workflow is schema-first and code-generated. You define your service in a .proto file. The gRPC compiler (protoc) generates type-safe client stubs and server interfaces in your language of choice. The generated code handles serialization, deserialization, and transport. You implement the server interface and call the client stub as if it were a local function.
Protocol Buffers: how binary encoding works
Protocol Buffers (protobuf) encode messages as binary sequences of field-number/value pairs. Field names are not transmitted on the wire; only the field number is. This is what makes the format compact and why it is schema-dependent: you cannot decode a protobuf message without the .proto definition.
From .proto definition to binary wire format
Schema (.proto)
message User {
int64 id = 1;
string name = 2;
string email = 3;
bool is_active = 4;
}
// Populated with:
// id: 42, name: "Alice",
// email: "a@ex.com", is_active: trueWire format (binary, 24 bytes)
08 2A field 1, varint, 42
12 05 41 6C 69 field 2, "Alice"
63 65
1A 07 61 40 65 field 3, "a@ex.com"
78 2E 63 6F
6D
20 01 field 4, bool true
vs JSON (67 bytes):
{"id":42,"name":"Alice",
"email":"a@ex.com",
"is_active":true}Field names absent on the wire. Unknown field numbers are silently skipped (forward compatibility). Missing fields use the type default (0, "", false). This is how .proto achieves backward and forward compatibility without versioning.
Scalar types and wire sizes
| Type | Wire type | Bytes | Notes |
|---|---|---|---|
| int32 / int64 | varint | 1-10 | Variable-length encoding; small numbers cost fewer bytes |
| float / double | fixed32 / fixed64 | 4 / 8 | Fixed size regardless of value |
| string | length-delimited | 2 + len | UTF-8 encoded; 2 bytes overhead for length |
| bool | varint | 1 | Encoded as 0 or 1 |
| bytes | length-delimited | 2 + len | Arbitrary binary; use for images, encrypted payloads |
| repeated T | packed varint | varies | Arrays. Packed encoding for numeric types |
| message | length-delimited | nested | Embedded messages, fully recursive |
HTTP/2 and multiplexing
REST over HTTP/1.1 opens one TCP connection per in-flight request (or reuses connections serially). HTTP/2 sends multiple streams over a single connection simultaneously using framing. Each request/response pair is a stream. Streams are independent: a slow response does not block a fast one (head-of-line blocking is eliminated at the HTTP layer).
Multiplexing
Multiple RPC calls share one TCP connection. No per-request connection overhead. No head-of-line blocking between streams.
Header compression
HPACK compresses HTTP headers. Repeated headers (Content-Type, Authorization) are sent once and referenced by index on subsequent frames.
Flow control
Each stream has a flow control window. Fast senders cannot overwhelm slow receivers. Works at both the stream and connection level.
Server push
Server can send frames the client has not requested yet. gRPC uses this for server-streaming and bidirectional RPCs.
Four streaming modes
rpc GetUser(UserRequest) returns (User);
Most API calls: fetch a resource, run a command
rpc WatchLogs(LogQuery) returns (stream LogEntry);
Live feeds, log tailing, export of large datasets
rpc UploadChunks(stream Chunk) returns (UploadResult);
File upload, batch ingest, streaming metrics
rpc Chat(stream Message) returns (stream Message);
Real-time chat, multiplayer, interactive sessions
When NOT to use gRPC
Public APIs
Browsers cannot call gRPC directly. gRPC-Web requires a proxy (Envoy, grpc-gateway). REST or GraphQL is far more accessible for public developer APIs.
Human-readable debugging
Binary frames are not readable with curl or browser DevTools without tooling. REST JSON is immediately inspectable. Protobuf requires protoc or a dedicated tool.
Teams new to the pattern
Schema-first development, code generation, and proto file management add upfront complexity. The ROI materializes at scale; it is overhead in a small service.
Firewall-restricted environments
Some corporate proxies and API gateways do not support HTTP/2 or pass binary frames correctly. REST over HTTP/1.1 has zero environment friction.
Choosing the Right Paradigm
REST, GraphQL, and gRPC are not competing alternatives where one wins. They are tools with different strengths that often coexist in the same system. The right choice depends on your client type, latency requirements, team experience, and schema complexity. No architecture uses a single paradigm end-to-end at scale.
Decision matrix
| Signal | REST | GraphQL | gRPC | Notes |
|---|---|---|---|---|
| Public API for third-party developers | best fit | workable | avoid | REST is universally accessible. No toolchain required. GraphQL works but requires clients to learn SDL. |
| Mobile app with varying data needs | avoid | best fit | workable | GraphQL eliminates over-fetching. Mobile on slow networks benefits most from exact-fit responses. |
| Internal microservice-to-microservice | workable | avoid | best fit | gRPC binary serialization and HTTP/2 multiplexing reduce latency and CPU at high RPS between services. |
| Real-time streaming (server push) | avoid | workable | best fit | gRPC server streaming is built in. GraphQL subscriptions work via WebSocket. REST requires SSE or polling. |
| Browser clients (no proxy) | best fit | best fit | avoid | Browsers cannot call gRPC directly. gRPC-Web needs an Envoy proxy. REST and GraphQL work natively. |
| Team needs discoverable, self-documenting API | workable | best fit | workable | GraphQL is introspectable. GraphiQL and Apollo Studio generate docs from the schema automatically. |
| Strict latency budget, high throughput | workable | workable | best fit | Binary frames, header compression, and multiplexed streams give gRPC a measurable edge at scale. |
| Strongly typed cross-language clients | workable | workable | best fit | protoc generates type-safe stubs in 10+ languages from a single .proto. No manual type sync. |
| Simple CRUD with standard HTTP caching | best fit | avoid | workable | REST GET responses are cached by CDNs and browsers out of the box. GraphQL POST requests are not. |
| Aggregating data from many services | avoid | best fit | workable | GraphQL's federation model (Apollo Federation, GraphQL Mesh) was built for this pattern. |
Hybrid architectures
Production systems rarely use one paradigm throughout. The most common hybrid patterns combine paradigms to get the best properties of each at the right layer of the stack.
REST for public + gRPC internally
External clients get REST endpoints. Internal services communicate via gRPC. A thin gateway translates. This is the approach used by Google, Netflix, and Uber.
Public: POST /orders | Internal: OrderService.CreateOrder (gRPC)
GraphQL BFF + gRPC backends
A Backend for Frontend layer (GraphQL) aggregates data from multiple gRPC microservices. Clients get the ergonomics of GraphQL. Services get the performance of gRPC.
Browser -> GraphQL BFF -> [UserService, PostService, CommentService] (gRPC)
REST for writes + GraphQL for reads
Mutations go through REST endpoints where HTTP semantics (201 Created, 409 Conflict, idempotency keys) are well understood. Queries go through GraphQL for flexible read patterns.
POST /payments (REST) + query { paymentHistory { ... } } (GraphQL)Rules of thumb
Start with REST
Unless you have a specific reason not to. REST is the path of least resistance: universal tooling, no build step, every developer knows it. Reach for GraphQL or gRPC when REST's limitations are actively hurting you.
Switch to GraphQL when over-fetching or waterfall fetching is measurable
If mobile clients are downloading 10x more data than they display, or if a page requires three sequential requests before it can render, GraphQL solves those problems directly.
Switch to gRPC for service-to-service at volume
When internal services are making thousands of calls per second to each other, gRPC's binary serialization and multiplexing pay for the operational cost of managing .proto files.
Never expose gRPC directly to browsers without a proxy
gRPC-Web via Envoy works, but it is an operational dependency. For browser-facing APIs, REST or GraphQL is always simpler.
Match the paradigm to the boundary, not the team preference
The public API boundary calls for REST. The inter-service boundary calls for gRPC. The frontend-to-aggregation boundary calls for GraphQL. These are engineering decisions, not personal style choices.