← All Posts
High Level Design Series · Foundations · Part 10

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:

Backward Compatibility

The cardinal rule: never break existing clients. This means:

Developer Experience (DX)

A great API feels like it was designed for you. Key DX principles:

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

HTTP Methods

MethodPurposeIdempotent?Safe?Request Body?
GETRead a resourceYesYesNo
POSTCreate a resourceNoNoYes
PUTReplace a resource entirelyYesNoYes
PATCHPartially update a resourceNo*NoYes
DELETERemove a resourceYesNoOptional

*PATCH can be idempotent if you use JSON Merge Patch, but isn't guaranteed to be.

HTTP Status Codes

CodeMeaningWhen to use
200OKSuccessful GET, PUT, PATCH, DELETE
201CreatedSuccessful POST that created a resource
204No ContentSuccessful DELETE with no response body
400Bad RequestMalformed request, validation failure
401UnauthorizedMissing or invalid authentication
403ForbiddenAuthenticated but lacks permission
404Not FoundResource doesn't exist
409ConflictDuplicate creation, concurrent modification
429Too Many RequestsRate limit exceeded
500Internal Server ErrorUnhandled 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

Concrete API Example: Twitter-like Service

POST /api/v1/tweets — Create a tweet

Request
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
}
Response — 201 Created
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

Request
GET /api/v1/users/42/timeline?limit=20&cursor=tw_98700 HTTP/1.1
Host: api.chirper.com
Authorization: Bearer eyJhbG...
Response — 200 OK
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

Request
POST /api/v1/tweets/98765/like HTTP/1.1
Authorization: Bearer eyJhbG...
Response — 200 OK
HTTP/1.1 200 OK

{
  "data": {
    "tweet_id": "98765",
    "liked": true,
    "likes_count": 43
  }
}

DELETE /api/v1/tweets/:id — Delete a tweet

Request
DELETE /api/v1/tweets/98765 HTTP/1.1
Authorization: Bearer eyJhbG...
Response — 204 No Content
HTTP/1.1 204 No Content

GET /api/v1/search/tweets — Search tweets

Request
GET /api/v1/search/tweets?q=systemdesign&sort=-likes_count&limit=10 HTTP/1.1
Authorization: Bearer eyJhbG...
Response — 200 OK
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.

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

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

AspectOffsetCursorKeyset
Random page accessYesNoNo
Performance at depthO(offset+limit)O(limit)O(limit)
Stable under insertsNoYesYes
Implementation complexityLowMediumMedium
Best forAdmin dashboards, small datasetsInfinite scroll, feedsFeeds, 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
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
      }
    }
  }
}
Response
{
  "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

ProblemRESTGraphQL
Over-fetching
Getting more data than needed
GET /users/42 returns 30 fields when you only need name and avatarClient specifies { user { name, avatar_url } }
Under-fetching
Needing multiple round trips
GET /users/42 + GET /users/42/posts + GET /users/42/followers = 3 requestsOne 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

ProsCons
No over-fetching or under-fetchingComplex caching (no HTTP-level caching by URL)
Strongly typed schema with introspectionN+1 if resolvers aren't batched
Single endpoint, flexible queriesDifficult 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.

// chirper.proto syntax = "proto3"; package chirper; message User { string id = 1; string username = 2; string email = 3; string bio = 4; int32 followers = 5; } message Post { string id = 1; string text = 2; string author_id = 3; int64 created_at = 4; // unix timestamp int32 likes_count = 5; } message GetUserRequest { string user_id = 1; } message GetUserResponse { User user = 1; repeated Post recent_posts = 2; } message CreatePostRequest { string text = 1; repeated string media_ids = 2; } message FeedRequest { int32 limit = 1; string cursor = 2; } message FeedResponse { repeated Post posts = 1; string next_cursor = 2; bool has_more = 3; } service ChirperService { // Unary RPC rpc GetUser (GetUserRequest) returns (GetUserResponse); rpc CreatePost (CreatePostRequest) returns (Post); // Server streaming — server sends multiple posts rpc GetFeed (FeedRequest) returns (stream Post); // Client streaming — client sends batch of events rpc StreamAnalytics (stream AnalyticsEvent) returns (AnalyticsSummary); // Bidirectional streaming — real-time chat rpc Chat (stream ChatMessage) returns (stream ChatMessage); }

Four Communication Patterns

PatternDescriptionUse Case
UnaryClient sends one request, server sends one responseStandard request/response (getUser, createPost)
Server StreamingClient sends one request, server streams multiple responsesLive feed updates, log tailing, large result sets
Client StreamingClient streams multiple requests, server sends one responseFile upload, batch analytics events, IoT sensor data
Bidirectional StreamingBoth sides stream simultaneouslyReal-time chat, multiplayer game state, collaborative editing

Binary Serialization & Performance

Protobuf encodes data in a compact binary format. Compared to JSON:

AspectJSON (REST)Protobuf (gRPC)
Serialization formatText (UTF-8)Binary
Payload size (typical)100%~30-50% of JSON
Serialization speedSlower (parse text)5-10x faster
Human readableYesNo (need protoc to decode)
TransportHTTP/1.1 or HTTP/2HTTP/2 (multiplexed, header compression)
Browser supportNativeNeeds gRPC-Web proxy
Code generationOptional (OpenAPI)Built-in (protoc)

When to Use gRPC

Use gRPC when: Internal service-to-service communication, polyglot microservices (protoc generates code for 10+ languages), high-throughput/low-latency requirements, streaming use cases.

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

  1. Handshake: Client sends an HTTP Upgrade request: GET /chat HTTP/1.1 with Upgrade: websocket
  2. Connection: Server responds 101 Switching Protocols, the TCP connection is promoted to WebSocket
  3. Full-duplex communication: Both sides send frames independently at any time
  4. 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

AspectWebSocketsSSELong Polling
DirectionBidirectionalServer → Client onlyServer → Client only
ProtocolWebSocket (ws/wss)HTTPHTTP
ConnectionPersistentPersistentRepeated requests
Browser supportAll modernAll modern (except IE)Universal
Auto-reconnectManualBuilt-inBuilt-in (by design)
Server complexityHigh (state per connection)MediumLow
ScalabilityHarder (stateful connections)ModerateEasier (stateless)
LatencyLowestLowVariable (up to timeout)
Best forChat, gaming, collaborationFeeds, notifications, dashboardsFallback, 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

Header Versioning

GET /api/users/42
Accept: application/vnd.chirper.v2+json

Query Parameter Versioning

GET /api/users/42?version=2

Semantic Versioning for APIs

Apply the MAJOR.MINOR.PATCH model:

In practice, only MAJOR versions appear in the URL. MINOR and PATCH changes are deployed transparently.

Deprecation Strategy

Golden rule of deprecation:
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 responses
3. 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

AlgorithmHow it worksProsCons
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

MethodHow it worksBest 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

// 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

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:

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:

Common Interview Mistakes

MistakeBetter Approach
Using verbs in URLs: POST /createUserPOST /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 endpointsAlways paginate — define limit and cursor params
Ignoring authenticationState "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 formatShow a consistent error envelope with code, message, details

Quick Reference: Choosing the Right API Style

ScenarioBest ChoiceWhy
Public API for third-party developersRESTUniversal, well-understood, great tooling
Mobile app with varying data needsGraphQLFetch exactly what each screen needs
Internal microservice communicationgRPCHigh performance, type safety, streaming
Real-time features (chat, live updates)WebSocketsFull-duplex, low latency
Simple server → client notificationsSSESimpler than WebSockets, auto-reconnect
Hybrid: public + internalREST + gRPCREST for public, gRPC between services