REST vs GraphQL vs gRPC, versioning strategies, authentication patterns, rate limiting, and the backend architecture decisions that define how well your system scales.
REST vs GraphQL vs gRPC, versioning strategies, authentication patterns, rate limiting, and the backend architecture decisions that define how well your system scales.
Lesson outline
An API is a promise to every developer who calls it. Breaking that promise — removing a field, changing a status code, redefining the semantics of an endpoint — breaks their code in production. Good API design means thinking about evolution, not just the current state.
The cost of a bad API compounds: once mobile clients depend on it, you cannot change it without a forced app update. Once 50 microservices call it, a breaking change requires coordinated deployment across 50 teams. Design as if you can never change it — because in practice, you often cannot.
Most APIs called "REST" are actually just JSON over HTTP. Real REST follows constraints: uniform interface, statelessness, cacheable responses, layered system.
Resource naming: Nouns, not verbs. `/users/42/orders` not `/getUserOrders`. Use plural nouns for collections (`/orders`), singular for specific resources (`/orders/42`).
HTTP methods semantics: GET (read, idempotent, cacheable), POST (create, not idempotent), PUT (replace entire resource, idempotent), PATCH (partial update), DELETE (remove, idempotent). Using POST for everything is a code smell.
Status codes mean something: 200 OK, 201 Created (with Location header), 204 No Content (successful DELETE), 400 Bad Request (client error, do not retry), 401 Unauthorized (missing/invalid auth), 403 Forbidden (valid auth, insufficient permissions), 404 Not Found, 409 Conflict (e.g. duplicate), 429 Too Many Requests, 500 Internal Server Error (server error, safe to retry with backoff).
Versioning: URI versioning (`/v1/users`) is most visible. Header versioning (`Accept: application/vnd.myapi.v2+json`) is more RESTful but less debuggable. Never version without a deprecation timeline — communicate sunset dates.
Additive changes are non-breaking
You can safely add new fields to responses, add new optional request parameters, and add new endpoints. You cannot remove fields, rename them, change their type, or change endpoint behavior. Design with this constraint in mind from day one.
1// ✅ GOOD REST API Design23// Resource-based URLs (nouns, not verbs)4GET /v1/orders // List orders (paginated)5POST /v1/orders // Create order → 201 + Location: /v1/orders/426GET /v1/orders/42 // Get order7PATCH /v1/orders/42 // Partial update (status, notes)8DELETE /v1/orders/42 // Cancel order → 204 No Content910// Nested resources for relationships11GET /v1/orders/42/items // Line items in this order12POST /v1/orders/42/items // Add item to order1314// ✅ GOOD Error Response (RFC 7807 Problem Details)RFC 7807 Problem Details is the industry standard for error responses15{16"type": "https://api.example.com/errors/insufficient-inventory",17"title": "Insufficient Inventory",18"status": 409,19"detail": "Product SKU-123 has 0 units available; requested 5",20"instance": "/v1/orders",21"requestId": "req_abc123", // for support tracing22"retryAfter": null // no point retrying23}2425// ✅ GOOD Pagination (cursor-based — does not drift on inserts)26GET /v1/orders?cursor=eyJpZCI6NDJ9&limit=202728// Response:29{30"data": [...],31"pagination": {32"nextCursor": "eyJpZCI6NjJ9",Cursor pagination is O(1) and stable — offset pagination is O(n) and drifts33"hasMore": true34}35}3637// ❌ BAD: Offset pagination drifts when new items are inserted38// GET /v1/orders?page=2&perPage=2039// If 5 new orders are inserted, page 2 shows duplicates from page 1
GraphQL lets clients request exactly the data they need — no over-fetching (getting more than needed) or under-fetching (needing multiple requests). One endpoint, flexible queries. Beloved by frontend teams.
When GraphQL wins: You have many client types (mobile, web, partners) with different data needs. Your data is a graph (social network, e-commerce product catalog with relationships). You want to eliminate multiple API round-trips.
When GraphQL hurts: N+1 query problem — if you have a list of 100 posts and each has an author, GraphQL naively makes 1 query for posts + 100 queries for authors. Fix with DataLoader (batch + deduplicate). HTTP caching is harder (all queries are POST to the same endpoint). Complexity in error handling (HTTP 200 even on errors).
DataLoader is not optional: Any production GraphQL server needs DataLoader for batching. Without it, a single query can generate thousands of database queries.
1import DataLoader from 'dataloader';2import { db } from './db';34// ❌ WITHOUT DataLoader — N+1 queries5// Query: { posts { id title author { name } } }6// Result: 1 DB query for posts + 100 DB queries for authors78// ✅ WITH DataLoader — 2 total queries regardless of list size9const userLoader = new DataLoader(async (userIds: readonly string[]) => {DataLoader batches calls within a single event loop tick10// Called ONCE with all user IDs batched together11const users = await db12.select()13.from(usersTable)14.where(inArray(usersTable.id, [...userIds]));1516// CRITICAL: Return in same order as input IDs17const userMap = new Map(users.map(u => [u.id, u]));Must return in same order as input — common gotcha18return userIds.map(id => userMap.get(id) ?? new Error(`User ${id} not found`));19});2021// GraphQL resolver — called per-post but batched by DataLoader22const postResolvers = {23Post: {24author: (post: Post) => userLoader.load(post.authorId),25},26};2728// Now 100 posts = 1 SQL query for posts + 1 SQL query for all authors29// SELECT * FROM users WHERE id IN (1, 2, 3, ..., 100)
gRPC uses Protocol Buffers (binary serialization) over HTTP/2. It is 5-10x faster than JSON REST for internal communication, supports streaming, and provides strong typing through `.proto` files.
When to use gRPC: Internal microservices communication where latency matters, streaming (server → client, client → server, bidirectional), polyglot environments (`.proto` generates clients in any language).
When NOT to use: Public APIs (browser support requires gRPC-Web proxy), teams unfamiliar with Protobuf, when debugging ease is more important than performance.
| Protocol | Payload | Speed | Streaming | Browser Support | Best For |
|---|---|---|---|---|---|
| REST/JSON | Text (verbose) | Baseline | ❌ (SSE only) | ✅ Native | Public APIs, external clients |
| GraphQL | Text (flexible) | Similar to REST | ✅ Subscriptions | ✅ Native | Frontend-heavy apps |
| gRPC | Binary (compact) | 5-10x faster | ✅ Bidirectional | ⚠️ gRPC-Web only | Internal microservices |
| WebSocket | Binary or Text | Very fast | ✅ Full-duplex | ✅ Native | Real-time features (chat, live) |
Rate limiting enforces a maximum request rate per client (by IP, API key, or user). Without it, a single misconfigured client can DDOS your service or run up your database bill.
Token bucket algorithm: Each client has a bucket of tokens (capacity N). Each request consumes 1 token. Tokens replenish at a fixed rate. Allows bursts up to N, then smooths to the replenishment rate. Most common for API rate limiting.
Leaky bucket: Requests are queued and processed at a fixed rate. No bursts allowed — good for smoothing traffic to a downstream service.
Sliding window: Count requests in the last N seconds using a circular buffer or Redis sorted set. More accurate than fixed window (which allows 2x the limit at window boundaries).
Where to enforce: API Gateway level (early rejection, protect entire backend), middleware (per-service policy), database query rate (protect the database from application layer)
1import { Redis } from 'ioredis';23const redis = new Redis();45// Sliding Window Rate Limiter using Redis Sorted Set6// Allows N requests per windowMs per key7async function isRateLimited(8key: string,9limit: number,10windowMs: number11): Promise<{ limited: boolean; remaining: number; resetAt: number }> {12const now = Date.now();13const windowStart = now - windowMs;Remove expired entries before counting — critical for accuracy1415const pipeline = redis.pipeline();Pipeline all Redis ops in one round-trip for performance16pipeline.zremrangebyscore(key, 0, windowStart); // Remove old entries17pipeline.zadd(key, now, `${now}-${Math.random()}`); // Add current request18pipeline.zcard(key); // Count requests in window19pipeline.pexpire(key, windowMs); // Auto-cleanup2021const results = await pipeline.exec();22const count = (results?.[2]?.[1] as number) ?? 0;2324return {25limited: count > limit,26remaining: Math.max(0, limit - count),27resetAt: now + windowMs,28};29}3031// Express middleware32export function rateLimitMiddleware(limit: number, windowMs: number) {33return async (req: Request, res: Response, next: NextFunction) => {34const key = `ratelimit:${req.ip}:${req.path}`;35const result = await isRateLimited(key, limit, windowMs);3637res.setHeader('X-RateLimit-Limit', limit);38res.setHeader('X-RateLimit-Remaining', result.remaining);Always return rate limit headers so clients can back off gracefully39res.setHeader('X-RateLimit-Reset', result.resetAt);4041if (result.limited) {42return res.status(429).json({43error: 'Too Many Requests',44retryAfter: Math.ceil(windowMs / 1000),45});46}47next();48};49}
API Keys: Simple, long-lived credentials for machine-to-machine. Store hashed (bcrypt), never log raw keys, support rotation. Good for external developer APIs.
JWT (JSON Web Tokens): Short-lived signed tokens issued after login. Self-contained (no database lookup needed to verify). Caveat: cannot be revoked until expiry unless you maintain a token blocklist. Use access tokens (15-60 min) + refresh tokens (long-lived, rotatable).
OAuth 2.0: Delegation protocol — let users grant your app access to their resources on another service (Google, GitHub). Use for "Login with Google" and any cross-service authorization.
mTLS (Mutual TLS): Both client and server present certificates. Used for service-to-service auth in zero-trust networks. Highest security, highest operational overhead.
JWT pitfall: do not store sensitive data
JWTs are base64-encoded, not encrypted. Anyone with the token can decode the payload. Never store passwords, PII, or secrets in a JWT. Sign them (prevent tampering) — consider encrypting (JWE) if the payload is sensitive.
API design interviews test both technical knowledge and product thinking — can you design an API another team would want to use?
Common questions:
Strong answers include:
Red flags:
Quick check · API Design & Backend Architecture
1 / 1
Key takeaways
From the books
API Design Patterns — JJ Geewax (2021)
Chapter 3: Naming, Chapter 7: Partial Updates, Chapter 12: Pagination
Google's internal API design guide (AIP — API Improvement Proposals) is the gold standard. Follow it even if you are not using Google Cloud — the patterns are battle-tested across thousands of APIs.
Ready to see how this works in the cloud?
Switch to Career Paths for structured paths (e.g. Developer, DevOps) and provider-specific lessons.
View role-based pathsSign in to track your progress and mark lessons complete.
Questions? Discuss in the community or start a thread below.
Join DiscordSign in to start or join a thread.