API Design: REST, GraphQL, gRPC
Every distributed system is held together by its APIs — the contracts that define how services talk to each other. A well-designed API is intuitive, evolvable, and performant. A poorly designed one creates coupling nightmares, versioning hell, and frustrated developers.
This post covers the three dominant API paradigms — REST, GraphQL, and gRPC — along with pagination, versioning, rate limiting, security, and how to present API design in system design interviews.
Why API Design Matters
An API is a contract. Once published, changing it breaks every client that depends on it. This creates immense pressure to get it right the first time — or at least to plan for evolution.
APIs as Contracts Between Services
In a microservices architecture, each service exposes an API that other services consume. The API defines:
- Endpoints — what operations are available
- Request format — what data clients must send
- Response format — what data clients receive
- Error semantics — how failures are communicated
- Authentication — how identity is proven
Backward Compatibility
The cardinal rule: never break existing clients. This means:
- Additive changes are safe: Adding a new field to a response, adding a new endpoint, adding an optional query parameter
- Breaking changes are dangerous: Removing a field, renaming a field, changing a field's type, removing an endpoint, making an optional field required
Developer Experience (DX)
A great API feels like it was designed for you. Key DX principles:
- Consistency — if
GET /usersreturns a list, thenGET /postsshould follow the same pattern - Predictability — developers should be able to guess the right endpoint
- Discoverability — good error messages, documentation, and tooling
- Idempotency — repeated identical requests produce the same result (critical for retries)
REST (Representational State Transfer)
REST is the most widely used API paradigm on the web. It was defined by Roy Fielding in his 2000 doctoral dissertation and builds on the existing infrastructure of HTTP.
Core Principles
- Resource-based: Everything is a resource identified by a URL.
/users/42is the resource "user 42". - Stateless: Each request contains all information needed to process it. The server stores no session state between requests.
- Uniform interface: Use standard HTTP methods with consistent semantics.
- Client-server separation: Client and server evolve independently.
- Cacheable: Responses explicitly declare whether they can be cached.
HTTP Methods
| Method | Purpose | Idempotent? | Safe? | Request Body? |
|---|---|---|---|---|
| GET | Read a resource | Yes | Yes | No |
| POST | Create a resource | No | No | Yes |
| PUT | Replace a resource entirely | Yes | No | Yes |
| PATCH | Partially update a resource | No* | No | Yes |
| DELETE | Remove a resource | Yes | No | Optional |
*PATCH can be idempotent if you use JSON Merge Patch, but isn't guaranteed to be.
HTTP Status Codes
| Code | Meaning | When to use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH, DELETE |
| 201 | Created | Successful POST that created a resource |
| 204 | No Content | Successful DELETE with no response body |
| 400 | Bad Request | Malformed request, validation failure |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but lacks permission |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Duplicate creation, concurrent modification |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unhandled server failure |
HATEOAS
Hypermedia As The Engine Of Application State — responses include links to related actions, so clients don't need to hardcode URLs:
{
"id": 42,
"username": "neelmishra",
"followers_count": 1200,
"_links": {
"self": { "href": "/api/v1/users/42" },
"followers": { "href": "/api/v1/users/42/followers" },
"posts": { "href": "/api/v1/users/42/posts" },
"follow": { "href": "/api/v1/users/42/follow", "method": "POST" }
}
}
In practice, few APIs achieve full HATEOAS, but including pagination links (next, prev) is common and valuable.
Richardson Maturity Model
Leonard Richardson defined four levels of REST maturity:
0The Swamp of POX — One URL, one HTTP method (usually POST). Essentially RPC-over-HTTP. Example: POST /api with {"action": "getUser", "id": 42}
1Resources — Individual URLs for resources, but still one HTTP method. Example: POST /users/42 with {"action": "get"}
2HTTP Verbs — Correct use of GET, POST, PUT, DELETE with proper status codes. This is where most production APIs live. Example: GET /users/42 returns 200 with user data.
3Hypermedia Controls — Full HATEOAS. Responses include links for next actions. Very few APIs reach this level.
REST Best Practices
- Use plural nouns for collections:
/usersnot/user,/postsnot/post - Nest resources logically:
/users/42/postsfor posts by user 42 - Keep nesting shallow: Maximum 2 levels deep.
/users/42/posts/7/commentsis fine; deeper is a smell. - Use query parameters for filtering, sorting, pagination:
/posts?author=42&sort=-created_at&page=2&limit=20 - Use kebab-case for URLs:
/user-profilesnot/userProfiles - Return created resources: A
POSTshould return the created resource with its server-assigned ID. - Use envelopes consistently:
{"data": [...], "meta": {"total": 100, "page": 2}}
Concrete API Example: Twitter-like Service
POST /api/v1/tweets — Create a tweet
POST /api/v1/tweets HTTP/1.1
Host: api.chirper.com
Authorization: Bearer eyJhbG...
Content-Type: application/json
{
"text": "Learning API design! #systemdesign",
"media_ids": ["img_abc123"],
"reply_to": null
}
HTTP/1.1 201 Created
Location: /api/v1/tweets/98765
{
"data": {
"id": "98765",
"text": "Learning API design! #systemdesign",
"author_id": "42",
"created_at": "2026-04-15T10:30:00Z",
"likes_count": 0,
"retweets_count": 0,
"reply_to": null,
"media": [
{ "id": "img_abc123", "url": "https://..." }
]
}
}
GET /api/v1/users/:id/timeline — Get user's timeline
GET /api/v1/users/42/timeline?limit=20&cursor=tw_98700 HTTP/1.1
Host: api.chirper.com
Authorization: Bearer eyJhbG...
HTTP/1.1 200 OK
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 297
{
"data": [
{
"id": "98765",
"text": "Learning API design!",
"author": {
"id": "42",
"username": "neelmishra",
"avatar_url": "https://..."
},
"created_at": "2026-04-15T10:30:00Z",
"likes_count": 42,
"retweets_count": 7
}
],
"meta": {
"next_cursor": "tw_98764",
"has_more": true
}
}
POST /api/v1/tweets/:id/like — Like a tweet
POST /api/v1/tweets/98765/like HTTP/1.1
Authorization: Bearer eyJhbG...
HTTP/1.1 200 OK
{
"data": {
"tweet_id": "98765",
"liked": true,
"likes_count": 43
}
}
DELETE /api/v1/tweets/:id — Delete a tweet
DELETE /api/v1/tweets/98765 HTTP/1.1
Authorization: Bearer eyJhbG...
HTTP/1.1 204 No Content
GET /api/v1/search/tweets — Search tweets
GET /api/v1/search/tweets?q=systemdesign&sort=-likes_count&limit=10 HTTP/1.1
Authorization: Bearer eyJhbG...
HTTP/1.1 200 OK
{
"data": [ ... ],
"meta": {
"total_results": 2340,
"next_cursor": "s_abc123",
"has_more": true
}
}
Pagination Strategies
Any endpoint that returns a list needs pagination. Without it, a query could return millions of rows and crash both the server and client. The three main approaches each have distinct trade-offs.
Offset-Based Pagination
GET /api/v1/tweets?page=2&limit=20
The server translates this to SELECT * FROM tweets ORDER BY id DESC LIMIT 20 OFFSET 20.
- Simple to implement and understand
- Clients can jump to any page
- Slow at large offsets —
OFFSET 1000000means the database scans and discards 1M rows - Inconsistent results when data changes — if a new tweet is inserted while paginating, items shift and you may see duplicates or miss items
Cursor-Based Pagination
GET /api/v1/tweets?cursor=eyJpZCI6OTg3NjV9&limit=20
The cursor is an opaque token (often a Base64-encoded primary key or timestamp). The server uses it to seek directly:
SELECT * FROM tweets WHERE id < 98765 ORDER BY id DESC LIMIT 20
- Constant performance regardless of page depth — uses index seek, not scan
- Stable results even when data changes
- Cannot jump to arbitrary pages ("show me page 50")
- Cursor must be opaque to clients (don't expose raw IDs)
Keyset-Based Pagination
GET /api/v1/tweets?after_id=98765&limit=20
Similar to cursor-based but with a transparent key. Works well when the ordering column has a unique index. The client explicitly passes the last seen value:
SELECT * FROM tweets WHERE id < :after_id ORDER BY id DESC LIMIT 20
Comparison Table
| Aspect | Offset | Cursor | Keyset |
|---|---|---|---|
| Random page access | Yes | No | No |
| Performance at depth | O(offset+limit) | O(limit) | O(limit) |
| Stable under inserts | No | Yes | Yes |
| Implementation complexity | Low | Medium | Medium |
| Best for | Admin dashboards, small datasets | Infinite scroll, feeds | Feeds, APIs with natural ordering |
▶ Offset vs Cursor Pagination
Watch how offset scans from the beginning while cursor jumps directly.
GraphQL
GraphQL, developed by Facebook in 2012 and open-sourced in 2015, is a query language for APIs. Instead of the server deciding the response shape, the client specifies exactly what data it needs.
Schema Definition
A GraphQL schema is a strongly-typed contract defined using the Schema Definition Language (SDL):
type User {
id: ID!
username: String!
email: String!
bio: String
followers_count: Int!
posts(first: Int = 10, after: String): PostConnection!
}
type Post {
id: ID!
text: String!
author: User!
created_at: DateTime!
likes_count: Int!
comments(first: Int = 5): [Comment!]!
}
type Comment {
id: ID!
text: String!
author: User!
created_at: DateTime!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
type Query {
user(id: ID!): User
post(id: ID!): Post
feed(first: Int = 20, after: String): PostConnection!
search(query: String!, first: Int = 10): [Post!]!
}
type Mutation {
createPost(text: String!, mediaIds: [ID!]): Post!
likePost(postId: ID!): Post!
followUser(userId: ID!): User!
deletePost(postId: ID!): Boolean!
}
type Subscription {
newPost(authorId: ID!): Post!
postLiked(postId: ID!): Post!
}
Queries
Clients request exactly the fields they need — no more, no less:
query GetUserWithPosts($userId: ID!) {
user(id: $userId) {
username
bio
followers_count
posts(first: 5) {
edges {
node {
id
text
likes_count
created_at
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
{
"data": {
"user": {
"username": "neelmishra",
"bio": "Building systems",
"followers_count": 1200,
"posts": {
"edges": [
{
"node": {
"id": "98765",
"text": "Learning GraphQL!",
"likes_count": 42,
"created_at": "2026-04-15T10:30:00Z"
}
}
],
"pageInfo": {
"hasNextPage": true,
"endCursor": "cG9zdDo5ODc2NA=="
}
}
}
}
}
Mutations
mutation CreatePost($text: String!) {
createPost(text: $text) {
id
text
created_at
author {
username
}
}
}
# Variables: { "text": "My first GraphQL mutation!" }
Subscriptions
Subscriptions use WebSockets to push real-time updates to clients:
subscription OnNewPost($authorId: ID!) {
newPost(authorId: $authorId) {
id
text
author { username }
created_at
}
}
The N+1 Problem & DataLoader
Consider fetching a list of posts with their authors. A naive resolver makes 1 query for the posts, then N separate queries for each post's author — the classic N+1 problem.
// Naive resolver — N+1 queries!
const resolvers = {
Post: {
author: (post) => db.users.findById(post.author_id) // called N times
}
};
// With DataLoader — batched into 1 query
const userLoader = new DataLoader(async (ids) => {
const users = await db.users.findByIds(ids); // SELECT * FROM users WHERE id IN (...)
return ids.map(id => users.find(u => u.id === id));
});
const resolvers = {
Post: {
author: (post) => userLoader.load(post.author_id) // batched automatically
}
};
DataLoader collects all .load() calls in a single event loop tick, then issues one batched query. This turns N+1 into 2 queries (1 for posts + 1 batch for all authors).
Over-fetching & Under-fetching
| Problem | REST | GraphQL |
|---|---|---|
| Over-fetching Getting more data than needed | GET /users/42 returns 30 fields when you only need name and avatar | Client specifies { user { name, avatar_url } } |
| Under-fetching Needing multiple round trips | GET /users/42 + GET /users/42/posts + GET /users/42/followers = 3 requests | One query fetches user, posts, and followers together |
Schema Stitching & Federation
Schema Stitching merges multiple GraphQL schemas into one. Useful for combining microservice APIs, but creates a single point of failure at the gateway.
Apollo Federation is the modern approach: each microservice owns a portion of the graph and declares how its types extend others:
# Users service
type User @key(fields: "id") {
id: ID!
username: String!
email: String!
}
# Posts service — extends User from users service
extend type User @key(fields: "id") {
id: ID! @external
posts: [Post!]!
}
type Post @key(fields: "id") {
id: ID!
text: String!
author: User!
}
GraphQL Pros & Cons
| Pros | Cons |
|---|---|
| No over-fetching or under-fetching | Complex caching (no HTTP-level caching by URL) |
| Strongly typed schema with introspection | N+1 if resolvers aren't batched |
| Single endpoint, flexible queries | Difficult to rate-limit (all queries hit one URL) |
| Excellent tooling (GraphiQL, Apollo DevTools) | File uploads are not natively supported |
| Evolves without versioning (deprecate fields) | Security: malicious deep/wide queries can DoS the server |
gRPC
gRPC (Google Remote Procedure Call) uses Protocol Buffers (protobuf) for serialization and HTTP/2 for transport. It's the go-to choice for high-performance internal microservice communication.
Protocol Buffers
Protobuf defines message schemas in .proto files. The protobuf compiler (protoc) generates strongly-typed client and server code in any language.
Four Communication Patterns
| Pattern | Description | Use Case |
|---|---|---|
| Unary | Client sends one request, server sends one response | Standard request/response (getUser, createPost) |
| Server Streaming | Client sends one request, server streams multiple responses | Live feed updates, log tailing, large result sets |
| Client Streaming | Client streams multiple requests, server sends one response | File upload, batch analytics events, IoT sensor data |
| Bidirectional Streaming | Both sides stream simultaneously | Real-time chat, multiplayer game state, collaborative editing |
Binary Serialization & Performance
Protobuf encodes data in a compact binary format. Compared to JSON:
| Aspect | JSON (REST) | Protobuf (gRPC) |
|---|---|---|
| Serialization format | Text (UTF-8) | Binary |
| Payload size (typical) | 100% | ~30-50% of JSON |
| Serialization speed | Slower (parse text) | 5-10x faster |
| Human readable | Yes | No (need protoc to decode) |
| Transport | HTTP/1.1 or HTTP/2 | HTTP/2 (multiplexed, header compression) |
| Browser support | Native | Needs gRPC-Web proxy |
| Code generation | Optional (OpenAPI) | Built-in (protoc) |
When to Use gRPC
Avoid gRPC when: Public-facing APIs (browsers), simple CRUD apps, teams unfamiliar with protobuf, debugging needs (binary is harder to inspect).
▶ REST vs GraphQL vs gRPC
Compare how each paradigm handles "get user with posts" — step through the request flow.
WebSockets
HTTP is request-response: the client asks, the server answers. But many features need real-time, server-initiated updates — chat messages, live scores, stock tickers, collaborative editing. WebSockets solve this.
Connection Lifecycle
- Handshake: Client sends an HTTP Upgrade request:
GET /chat HTTP/1.1withUpgrade: websocket - Connection: Server responds
101 Switching Protocols, the TCP connection is promoted to WebSocket - Full-duplex communication: Both sides send frames independently at any time
- Close: Either side sends a close frame; connection is torn down
// Client-side WebSocket
const ws = new WebSocket('wss://api.chirper.com/ws/feed');
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'subscribe', channel: 'user:42:feed' }));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'new_post') {
renderPost(msg.data);
}
};
ws.onclose = (event) => {
console.log(`Closed: ${event.code} ${event.reason}`);
// Reconnect with exponential backoff
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
Server-Sent Events (SSE)
SSE is a simpler alternative for one-way server-to-client streaming over HTTP:
// Server sends events over HTTP
GET /api/v1/stream/feed HTTP/1.1
Accept: text/event-stream
// Server response — keeps connection open
HTTP/1.1 200 OK
Content-Type: text/event-stream
event: new_post
data: {"id":"98765","text":"Hello!","author":"neelmishra"}
event: new_post
data: {"id":"98766","text":"Another post!","author":"janedoe"}
event: heartbeat
data: {"timestamp":"2026-04-15T10:30:00Z"}
// Client-side SSE
const source = new EventSource('/api/v1/stream/feed');
source.addEventListener('new_post', (e) => {
const post = JSON.parse(e.data);
renderPost(post);
});
Long Polling
The simplest real-time fallback: the client makes a GET request, and the server holds the connection open until new data is available (or a timeout occurs):
// Long polling loop
async function poll() {
while (true) {
const response = await fetch('/api/v1/feed/poll?last_id=98764&timeout=30');
const data = await response.json();
if (data.posts.length > 0) {
data.posts.forEach(renderPost);
}
// Immediately poll again
}
}
Real-Time Approaches Comparison
| Aspect | WebSockets | SSE | Long Polling |
|---|---|---|---|
| Direction | Bidirectional | Server → Client only | Server → Client only |
| Protocol | WebSocket (ws/wss) | HTTP | HTTP |
| Connection | Persistent | Persistent | Repeated requests |
| Browser support | All modern | All modern (except IE) | Universal |
| Auto-reconnect | Manual | Built-in | Built-in (by design) |
| Server complexity | High (state per connection) | Medium | Low |
| Scalability | Harder (stateful connections) | Moderate | Easier (stateless) |
| Latency | Lowest | Low | Variable (up to timeout) |
| Best for | Chat, gaming, collaboration | Feeds, notifications, dashboards | Fallback, simple updates |
API Versioning
APIs evolve. At some point you'll need breaking changes. Versioning lets you maintain the old contract while introducing the new one.
URL Versioning
GET /api/v1/users/42
GET /api/v2/users/42
- Most common, most visible, easy to route
- Easy to document, test, and cache separately
- Duplicates URLs —
/v1/usersand/v2/usersmight be 95% the same code - Philosophically impure — the resource is the same, the representation changed
Header Versioning
GET /api/users/42
Accept: application/vnd.chirper.v2+json
- Clean URLs that don't change across versions
- Follows HTTP content negotiation semantics
- Harder to test (can't just paste a URL in a browser)
- Easy to forget the header, leading to ambiguous behavior
Query Parameter Versioning
GET /api/users/42?version=2
- Easy to add to existing APIs
- Pollutes the query string, can conflict with other params
- Cache busting issues (same URL, different responses)
Semantic Versioning for APIs
Apply the MAJOR.MINOR.PATCH model:
- MAJOR (v1 → v2): Breaking changes — removed fields, changed types, restructured responses
- MINOR: New features, new optional fields — backward compatible
- PATCH: Bug fixes, performance improvements — no API changes
In practice, only MAJOR versions appear in the URL. MINOR and PATCH changes are deployed transparently.
Deprecation Strategy
1. Announce deprecation with a sunset date (e.g.,
Sunset: Sat, 01 Jan 2028 00:00:00 GMT header)2. Add
Deprecation: true header to old version responses3. Log usage of deprecated endpoints, contact heavy users
4. Run both versions in parallel for 6-12 months
5. Return
410 Gone after the sunset date
Rate Limiting & Throttling
Rate limiting protects your API from abuse, ensures fair usage, and prevents cascade failures. Every production API needs it.
Standard Headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 300 # max requests per window
X-RateLimit-Remaining: 297 # requests left in current window
X-RateLimit-Reset: 1713180000 # Unix timestamp when window resets
429 Too Many Requests
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit exceeded. Try again in 30 seconds.",
"retry_after": 30
}
}
Common Rate Limiting Algorithms
| Algorithm | How it works | Pros | Cons |
|---|---|---|---|
| Fixed Window | Count requests in fixed time windows (e.g., 100/minute) | Simple, low memory | Burst at window boundaries (double rate) |
| Sliding Window Log | Track timestamp of each request, count those within window | Accurate, smooth | High memory (store every timestamp) |
| Sliding Window Counter | Weighted average of current + previous window | Good accuracy, low memory | Slight approximation |
| Token Bucket | Tokens added at fixed rate; each request consumes a token | Allows controlled bursts | Slightly more complex |
| Leaky Bucket | Requests enter a queue processed at fixed rate | Smooth output rate | Latency for queued requests |
Client-Side Handling
async function apiCall(url, options, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After')) || 1;
const backoff = retryAfter * 1000 * Math.pow(2, attempt); // exponential backoff
console.warn(`Rate limited. Retrying in ${backoff}ms`);
await new Promise(resolve => setTimeout(resolve, backoff));
continue;
}
return response;
}
throw new Error('Max retries exceeded');
}
API Security
Authentication Methods
| Method | How it works | Best for |
|---|---|---|
| API Keys | Static token in header: X-API-Key: abc123 |
Server-to-server, public APIs with usage tracking |
| OAuth 2.0 | Token exchange protocol with scopes, refresh tokens | Third-party access, user-authorized apps |
| JWT (JSON Web Tokens) | Self-contained signed token: Authorization: Bearer eyJ... |
Stateless auth, microservices |
| mTLS | Mutual TLS — both client and server present certificates | Internal service mesh, zero-trust architectures |
OAuth 2.0 Flow (Authorization Code)
1. Client redirects user to authorization server:
GET /authorize?response_type=code&client_id=APP_ID
&redirect_uri=https://app.com/callback&scope=read+write
2. User logs in and grants permission
3. Auth server redirects back with code:
GET /callback?code=AUTH_CODE_XYZ
4. Client exchanges code for tokens:
POST /token
grant_type=authorization_code&code=AUTH_CODE_XYZ
&client_id=APP_ID&client_secret=APP_SECRET
5. Auth server returns:
{ "access_token": "eyJ...", "refresh_token": "dGhp...", "expires_in": 3600 }
6. Client uses access token:
GET /api/v1/users/me
Authorization: Bearer eyJ...
JWT Structure
// Header
{ "alg": "RS256", "typ": "JWT" }
// Payload
{
"sub": "user_42",
"iss": "auth.chirper.com",
"aud": "api.chirper.com",
"exp": 1713180000,
"iat": 1713176400,
"scope": "read write",
"roles": ["user"]
}
// Signature
RSASHA256(base64(header) + "." + base64(payload), privateKey)
// Full token: xxxxx.yyyyy.zzzzz
CORS (Cross-Origin Resource Sharing)
// Preflight request
OPTIONS /api/v1/tweets HTTP/1.1
Origin: https://app.chirper.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Authorization, Content-Type
// Server response
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.chirper.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Input Validation & Injection Prevention
- Validate all input: Type checking, length limits, allowed characters, range checks
- Parameterize queries: Never concatenate user input into SQL — use prepared statements
- Sanitize output: Escape HTML to prevent XSS when rendering user content
- Limit request body size: Reject payloads larger than expected (e.g., 1MB max)
- Use allowlists over denylists: Define what's allowed, not what's forbidden
// BAD — SQL injection vulnerable
const query = `SELECT * FROM users WHERE id = '${req.params.id}'`;
// GOOD — parameterized query
const query = 'SELECT * FROM users WHERE id = $1';
const result = await db.query(query, [req.params.id]);
Security Checklist
- ✅ Use HTTPS everywhere (TLS 1.3)
- ✅ Authenticate every request (API key, JWT, or OAuth)
- ✅ Authorize at the resource level (check ownership, not just authentication)
- ✅ Rate limit by user/IP/API key
- ✅ Validate and sanitize all input
- ✅ Log security events (failed auth, rate limits, suspicious patterns)
- ✅ Set security headers (
X-Content-Type-Options,X-Frame-Options,Strict-Transport-Security) - ✅ Rotate secrets and tokens regularly
- ✅ Use short-lived access tokens with refresh tokens
API Design in Interviews
In system design interviews, you'll almost always need to define API endpoints. Here's a systematic approach.
Step 1: Identify Core Resources
For a Twitter-like system: Users, Tweets, Follows, Likes, Timelines.
Step 2: Define Endpoints
# Users
POST /api/v1/users # Register
GET /api/v1/users/:id # Get profile
PATCH /api/v1/users/:id # Update profile
# Tweets
POST /api/v1/tweets # Create tweet
GET /api/v1/tweets/:id # Get tweet
DELETE /api/v1/tweets/:id # Delete tweet
# Timeline
GET /api/v1/users/:id/timeline # User's home timeline
GET /api/v1/users/:id/tweets # User's own tweets
# Social
POST /api/v1/users/:id/follow # Follow a user
DELETE /api/v1/users/:id/follow # Unfollow
GET /api/v1/users/:id/followers # List followers
GET /api/v1/users/:id/following # List following
# Interactions
POST /api/v1/tweets/:id/like # Like
DELETE /api/v1/tweets/:id/like # Unlike
POST /api/v1/tweets/:id/retweet # Retweet
# Search
GET /api/v1/search/tweets?q=... # Search tweets
GET /api/v1/search/users?q=... # Search users
Step 3: Request/Response Examples
Pick 2-3 key endpoints and show concrete examples (as we did in the REST section). Interviewers love seeing you think through:
- What fields go in the request body vs URL vs query params?
- What does the response look like? What fields are included?
- How do you handle errors? What status codes?
- How is pagination implemented?
Step 4: Error Handling Pattern
// Consistent error response format
{
"error": {
"code": "TWEET_NOT_FOUND",
"message": "Tweet with ID 98765 does not exist.",
"status": 404,
"details": {
"tweet_id": "98765"
},
"request_id": "req_abc123"
}
}
// Validation errors with field-level detail
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed.",
"status": 400,
"details": {
"fields": [
{ "field": "text", "error": "Must be between 1 and 280 characters" },
{ "field": "media_ids", "error": "Maximum 4 media attachments allowed" }
]
},
"request_id": "req_def456"
}
}
Step 5: Call Out Non-Functional Concerns
Mention these proactively — they show depth:
- Idempotency: "POST /tweets could use an idempotency key header to prevent duplicate tweets on retry"
- Pagination: "For the timeline, I'd use cursor-based pagination for consistency"
- Rate limiting: "Different limits per endpoint: timeline reads get 300/min, tweet creation gets 30/min"
- Caching: "GET /users/:id could use
Cache-Control: max-age=60with ETags for conditional requests" - Versioning: "URL versioning with /v1/ prefix for simplicity"
Common Interview Mistakes
| Mistake | Better Approach |
|---|---|
Using verbs in URLs: POST /createUser | POST /users — the HTTP method implies the action |
Returning 200 for errors with {"success": false} | Use proper HTTP status codes (400, 404, 500) |
| No pagination on list endpoints | Always paginate — define limit and cursor params |
| Ignoring authentication | State "Bearer token in Authorization header" at the start |
| Inconsistent naming (camelCase vs snake_case mixed) | Pick one convention (snake_case is most common) and stick to it |
| No error response format | Show a consistent error envelope with code, message, details |
Quick Reference: Choosing the Right API Style
| Scenario | Best Choice | Why |
|---|---|---|
| Public API for third-party developers | REST | Universal, well-understood, great tooling |
| Mobile app with varying data needs | GraphQL | Fetch exactly what each screen needs |
| Internal microservice communication | gRPC | High performance, type safety, streaming |
| Real-time features (chat, live updates) | WebSockets | Full-duplex, low latency |
| Simple server → client notifications | SSE | Simpler than WebSockets, auto-reconnect |
| Hybrid: public + internal | REST + gRPC | REST for public, gRPC between services |