API Design Best Practices - Building Production-Grade REST and GraphQL APIs
Master API design with REST best practices, GraphQL optimization, versioning strategies, authentication patterns, rate limiting, comprehensive documentation, and monitoring for scalable production APIs.
Introduction
API design determines developer experience, system scalability, and long-term maintainability more than any other architectural decision, with poorly designed APIs creating technical debt measured in years—Stripe reports 10% of engineering time resolving breaking changes from early API mistakes, while Twilio attributes 40% faster partner integration to consistent RESTful conventions. Well-designed APIs enable rapid third-party integration (Shopify's API ecosystem drives $444B GMV annually), support backward compatibility during evolution (AWS hasn't broken API contracts in 18 years), and scale to billions of requests daily (Twitter API serves 500+ billion requests monthly). Yet 67% of API failures trace to design flaws rather than infrastructure issues according to Postman's 2025 State of API Report—inconsistent naming conventions confuse developers, missing pagination crashes clients on large datasets, inadequate error messages require support tickets, and breaking changes without versioning strategies break production integrations.
Modern API design demands balancing competing concerns: RESTful principles (resource-oriented URLs, HTTP verb semantics) versus pragmatic shortcuts (composite endpoints reducing roundtrips), strong typing (GraphQL schemas, OpenAPI specifications) versus flexibility (accepting varied input formats), security (OAuth 2.0, API keys, rate limiting) versus developer convenience (simple authentication, generous quotas), and backward compatibility (versioning, deprecation policies) versus rapid feature development. Organizations implementing systematic API design practices report 50-70% reduction in integration time for partners, 60-80% decrease in support tickets from API consumers, and 10x improvement in API adoption through superior developer experience. Companies like Stripe achieve 95% developer satisfaction through comprehensive documentation and consistent conventions, GitHub enables 100+ million developers through well-designed REST + GraphQL hybrid approach, and Twilio supports 10+ million phone numbers with APIs processing 200+ billion requests annually.
This guide explores production-proven API design patterns including REST API fundamentals (resource modeling, HTTP methods, status codes), GraphQL schema design (query optimization, N+1 problem solutions, caching strategies), API versioning approaches (URL versioning, header versioning, content negotiation), authentication and authorization (OAuth 2.0, JWT, API keys), rate limiting and throttling (token bucket, leaky bucket, distributed rate limiting), comprehensive documentation (OpenAPI/Swagger, interactive docs), and observability (request tracing, error monitoring, performance analytics). Whether designing greenfield API for internal microservices or public API serving millions of developers, understanding these patterns determines whether API becomes competitive advantage or maintenance burden requiring constant firefighting.
REST API Design Fundamentals
Resource-Oriented URL Design
REST APIs model system entities as resources accessed through predictable URL patterns.
Resource Naming Conventions:
# Good: Plural nouns, hierarchical relationships
GET /users # List users
GET /users/123 # Get specific user
POST /users # Create user
PUT /users/123 # Update user (full replacement)
PATCH /users/123 # Update user (partial)
DELETE /users/123 # Delete user
Nested resources (has-a relationships)
GET /users/123/orders # User's orders
GET /users/123/orders/456 # Specific order for user
POST /users/123/orders # Create order for user
Avoid: Verbs in URLs, actions as endpoints
❌ POST /createUser # Use POST /users instead
❌ GET /getUserOrders/123 # Use GET /users/123/orders
❌ POST /users/123/activate # Use PATCH /users/123 with status field
Exception: Non-CRUD operations requiring verbs
POST /users/123/password/reset # Trigger password reset
POST /orders/456/refund # Process refund
POST /reports/generate # Generate report
Query Parameters for Filtering, Sorting, Pagination:
# Filtering
GET /products?category=electronics&price_min=100&price_max=500
GET /users?status=active&created_after=2026-01-01
Sorting
GET /products?sort=price:asc # Ascending price
GET /products?sort=-created_at # Descending (- prefix)
GET /products?sort=category,price:desc # Multiple fields
Pagination (offset-based)
GET /products?page=2&per_page=50
GET /products?offset=100&limit=50
Pagination (cursor-based for large datasets)
GET /products?cursor=eyJpZCI6MTIzfQ&limit=50
Search
GET /products?q=laptop&category=electronics
HTTP Method Semantics
Consistent HTTP verb usage signals operation intent and enables HTTP caching.
Standard HTTP Methods:
GET: Retrieve resource(s), idempotent, cacheable
GET /users/123 → 200 OK with user data
GET /users → 200 OK with array of users
POST: Create resource, non-idempotent
POST /users with body → 201 Created, Location: /users/124
POST /orders → 201 Created
PUT: Full replacement, idempotent
PUT /users/123 with complete user object → 200 OK or 204 No Content
Missing fields in body = deleted from resource
PATCH: Partial update, should be idempotent
PATCH /users/123 with {email: "new@example.com"} → 200 OK
Only updates specified fields
DELETE: Remove resource, idempotent
DELETE /users/123 → 204 No Content or 200 OK with deleted resource
DELETE /users/123 (repeated) → 404 Not Found (already deleted)
HEAD: Same as GET but without body (check existence, get metadata)
HEAD /users/123 → 200 OK with headers only
OPTIONS: Discover allowed methods
OPTIONS /users/123 → 200 OK with Allow: GET, PUT, PATCH, DELETE header
Idempotency Guarantees:
// GET, PUT, DELETE are idempotent (repeating produces same result)
// Example: Deleting same resource twice
DELETE /users/123 // First call: 204 No Content (user deleted)
DELETE /users/123 // Second call: 404 Not Found (already gone, same end state)
// POST is NOT idempotent (creates multiple resources)
POST /orders { product_id: 5, quantity: 1 } // Creates order #100
POST /orders { product_id: 5, quantity: 1 } // Creates order #101 (duplicate!)
// Solution: Idempotency keys for POST requests
POST /orders
Headers:
Idempotency-Key: client-generated-uuid-123
Body:
{ product_id: 5, quantity: 1 }
// Server deduplicates using key
// First request: Creates order #100
// Second request with same key: Returns order #100 (no duplicate)
HTTP Status Codes
Proper status codes enable clients to handle responses programmatically without parsing body.
Success Codes (2xx):
200 OK: Successful GET, PUT, PATCH, DELETE (with response body)
GET /users/123 → 200 + user JSON
201 Created: Successful POST creating resource
POST /users → 201 + Location: /users/124 header + created user JSON
202 Accepted: Request accepted for async processing
POST /reports/generate → 202 + {job_id: "abc123", status_url: "/jobs/abc123"}
204 No Content: Successful request with no response body
DELETE /users/123 → 204 (user deleted, nothing to return)
PUT /users/123 → 204 (updated, no need to return resource)
Client Error Codes (4xx):
400 Bad Request: Invalid request body or parameters
POST /users with {email: "invalid-email"} → 400 + error details
401 Unauthorized: Missing or invalid authentication
GET /admin/users without Authorization header → 401
403 Forbidden: Authenticated but lacking permissions
DELETE /users/456 (trying to delete someone else's account) → 403
404 Not Found: Resource doesn't exist
GET /users/999999 → 404
409 Conflict: Request conflicts with current state
POST /users with email already in use → 409 + error details
422 Unprocessable Entity: Validation failed
POST /users with password too short → 422 + validation errors
429 Too Many Requests: Rate limit exceeded
(After 1000 requests/hour) → 429 + Retry-After: 3600 header
Server Error Codes (5xx):
500 Internal Server Error: Generic server failure
GET /users/123 (database connection failed) → 500
502 Bad Gateway: Upstream service returned invalid response
GET /users/123 (user service returned garbage) → 502
503 Service Unavailable: Temporary unavailability (maintenance, overload)
GET /users/123 (database overloaded) → 503 + Retry-After: 60
504 Gateway Timeout: Upstream service timeout
GET /orders (inventory service didn't respond in 30s) → 504
Error Response Format:
// Consistent error structure enables client error handling
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "email",
"message": "Email must be valid email address",
"value": "invalid-email"
},
{
"field": "password",
"message": "Password must be at least 8 characters",
"value": "***"
}
],
"request_id": "req_abc123",
"documentation_url": "https://api.example.com/docs/errors/validation"
}
}
Pagination Strategies
Large result sets require pagination preventing memory exhaustion and timeouts.
Offset-Based Pagination (Simple, Inefficient at Scale):
GET /products?page=2&per_page=50
Response:
{
"data": [...50 products...],
"pagination": {
"page": 2,
"per_page": 50,
"total_pages": 200,
"total_count": 10000
},
"links": {
"first": "/products?page=1&per_page=50",
"prev": "/products?page=1&per_page=50",
"next": "/products?page=3&per_page=50",
"last": "/products?page=200&per_page=50"
}
}
Problems:
- Slow for large offsets (OFFSET 10000 scans 10000 rows)
- Inconsistent results if data changes during pagination
- Requires COUNT(*) for total (expensive on large tables)
Cursor-Based Pagination (Scalable, Consistent):
GET /products?cursor=eyJpZCI6MTAwMH0&limit=50
Response:
{
"data": [...50 products...],
"pagination": {
"next_cursor": "eyJpZCI6MTA1MH0",
"has_more": true
},
"links": {
"next": "/products?cursor=eyJpZCI6MTA1MH0&limit=50"
}
}
Cursor is base64-encoded pointer: {"id": 1000, "created_at": "2026-02-14T12:00:00Z"}
SQL implementation:
SELECT * FROM products
WHERE (created_at, id) > ('2026-02-14T12:00:00Z', 1000)
ORDER BY created_at, id
LIMIT 50;
Benefits:
- Constant time regardless of dataset size (indexed WHERE clause)
- Consistent results even with concurrent modifications
- No need for COUNT(*) query
Keyset Pagination (Alternative to Cursor):
# Similar to cursor but uses human-readable values
GET /products?after_id=1000&limit=50
Or with multiple fields:
GET /products?after_date=2026-02-14T12:00:00Z&after_id=1000&limit=50
More transparent than opaque cursors
Easier debugging and manual API exploration
GraphQL API Design
Schema Design Principles
GraphQL schemas define type system enabling strong typing and introspection.
Type Definitions:
# Object types represent entities
type User {
id: ID! # Non-null ID
email: String! # Non-null String
name: String # Nullable String
createdAt: DateTime! # Custom scalar
orders: [Order!]! # Non-null array of non-null Orders
profile: Profile # Nullable related object
}
type Order {
id: ID!
user: User! # Back-reference to User
items: [OrderItem!]!
total: Money! # Custom scalar for currency
status: OrderStatus! # Enum
createdAt: DateTime!
}
enum OrderStatus {
PENDING
PAID
SHIPPED
DELIVERED
CANCELLED
}
Custom scalars for domain types
scalar DateTime
scalar Money
scalar Email
Query and Mutation Design:
type Query {
# Single resource by ID
user(id: ID!): User
List with filtering and pagination
users(
filter: UserFilter
orderBy: UserOrderBy
first: Int
after: String
): UserConnection!
Search
searchProducts(query: String!, first: Int): [Product!]!
}
input UserFilter {
status: UserStatus
createdAfter: DateTime
createdBefore: DateTime
}
enum UserOrderBy {
CREATED_AT_ASC
CREATED_AT_DESC
NAME_ASC
NAME_DESC
}
Relay-style connection pattern for pagination
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Mutation {
Input types for mutations
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
}
input CreateUserInput {
email: Email!
name: String!
password: String!
}
Payload types return created object + metadata
type CreateUserPayload {
user: User!
errors: [UserError!]
}
type UserError {
field: String!
message: String!
}
Solving the N+1 Query Problem
GraphQL's flexibility creates N+1 query problem where fetching list + relations generates N additional database queries.
N+1 Problem Example:
query {
users(first: 100) { # 1 query: SELECT * FROM users LIMIT 100
id
name
orders { # 100 queries: SELECT * FROM orders WHERE user_id = ?
id # (One query per user!)
total
}
}
}
Total: 101 database queries for single GraphQL query
On large datasets: 10,000 users = 10,001 queries (disaster!)
Solution: DataLoader Batching:
// DataLoader batches and caches requests within single GraphQL execution
const DataLoader = require('dataloader');
// Batch function loads multiple user IDs in single query
const orderLoader = new DataLoader(async (userIds) => {
// Single query for all user IDs
const orders = await db.query(
'SELECT * FROM orders WHERE user_id = ANY($1)',
[userIds]
);
// Group orders by user_id
const ordersByUserId = {};
orders.forEach(order => {
if (!ordersByUserId[order.user_id]) {
ordersByUserId[order.user_id] = [];
}
ordersByUserId[order.user_id].push(order);
});
// Return in same order as input user IDs
return userIds.map(id => ordersByUserId[id] || []);
});
// GraphQL resolvers use loader
const resolvers = {
User: {
orders: (user, args, context) => {
// DataLoader batches all order requests
return context.loaders.orderLoader.load(user.id);
}
}
};
// Result: 1 query for users + 1 batched query for all orders = 2 queries total
// 50x improvement over 101 queries!
Join Hints with Prisma/TypeORM:
// Alternative: Use ORM to eager load relations
const users = await prisma.user.findMany({
take: 100,
include: {
orders: true, // JOIN orders in single query
profile: true // JOIN profile
}
});
// Generates single SQL with JOINs:
// SELECT users., orders., profile.*
// FROM users
// LEFT JOIN orders ON orders.user_id = users.id
// LEFT JOIN profile ON profile.user_id = users.id
// LIMIT 100;
Query Complexity and Depth Limiting
Malicious or poorly-written queries can overwhelm server with expensive operations.
Query Depth Limiting:
# Dangerous: Deeply nested query
query {
user(id: "1") {
orders {
items {
product {
category {
products { # Depth 5
reviews { # Depth 6
user { # Depth 7
orders { # Depth 8 - exponential explosion!
...
// Limit query depth
const depthLimit = require('graphql-depth-limit');
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(5) // Reject queries deeper than 5 levels
]
});
// Reject query with error:
// "Query exceeds maximum depth of 5. Found depth of 8."
Query Cost Analysis:
// Assign cost to fields, reject expensive queries
const costAnalysis = require('graphql-cost-analysis');
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
costAnalysis({
maximumCost: 1000,
defaultCost: 1,
costMap: {
User: {
orders: { complexity: 10 }, // Fetching orders is expensive
posts: { complexity: 10 }
},
Order: {
items: { complexity: 5 }
}
}
})
]
});
// Query cost calculation:
query {
users(first: 100) { # 100 users
orders(first: 10) { # 100 * 10 * 10 (cost) = 10,000
items { # 100 * 10 * 10 * 5 (cost) = 50,000
product
}
}
}
}
Total cost: 60,000 - exceeds limit of 1,000, rejected
API Versioning Strategies
URL Versioning (Most Common)
Version in URL path provides explicit, visible versioning.
Implementation:
# Version in URL path
GET /v1/users/123
GET /v2/users/123
Pros:
- Visible in browser, easy debugging
- Can route different versions to different servers
- Clear deprecation (disable /v1 endpoint entirely)
Cons:
- Resource URLs not stable across versions
- Violates REST principle of stable resource identifiers
Example: Stripe API
GET https://api.stripe.com/v1/charges
Version-Specific Routing:
// Express.js example
const express = require('express');
const app = express();
// Version 1 routes
const v1Router = express.Router();
v1Router.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json({
id: user.id,
email: user.email,
name: user.name
// V1 format
});
});
// Version 2 routes (breaking changes)
const v2Router = express.Router();
v2Router.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json({
id: user.id,
email: user.email,
full_name: user.name, // Renamed field (breaking change)
created_at: user.createdAt, // New field
profile: { // Nested structure (breaking change)
avatar_url: user.avatarUrl
}
});
});
app.use('/v1', v1Router);
app.use('/v2', v2Router);
Header Versioning (RESTful)
Version in Accept header maintains stable URLs.
Implementation:
# Same URL, version in header
GET /users/123
Accept: application/vnd.example.v1+json
GET /users/123
Accept: application/vnd.example.v2+json
Pros:
- URLs stable across versions (RESTful)
- Content negotiation (same URL, different representations)
Cons:
- Less discoverable (not visible in URL)
- Harder debugging (need to inspect headers)
- Can't easily test in browser
Example: GitHub API
GET https://api.github.com/users/octocat
Accept: application/vnd.github.v3+json
Server-Side Handling:
app.get('/users/:id', async (req, res) => {
const acceptHeader = req.headers['accept'] || '';
const version = parseVersion(acceptHeader); // Extract v1, v2, etc.
const user = await db.users.findById(req.params.id);
if (version === 'v1') {
res.json(formatUserV1(user));
} else if (version === 'v2') {
res.json(formatUserV2(user));
} else {
// Default to latest
res.json(formatUserV2(user));
}
});
function parseVersion(acceptHeader) {
const match = acceptHeader.match(/application/vnd.example.(v\d+)+json/);
return match ? match[1] : 'v2'; // Default to v2
}
Deprecation Policies
Communicate breaking changes and removal timelines to API consumers.
Deprecation Headers:
# Warn clients about deprecated endpoints
GET /v1/users/123
Response Headers:
Deprecation: true
Sunset: Wed, 01 Jan 2027 00:00:00 GMT
Link: </v2/users/123>; rel="alternate"
Warning: 299 - "API v1 is deprecated. Migrate to v2 by Jan 2027. See https://api.example.com/docs/migration"
Versioning Best Practices:
1. Never remove features without deprecation period (6-12 months minimum)
-
Support N-1 versions (current + previous version)
- V3 released: Support V3 and V2
- V4 released: Support V4 and V3, deprecate V2
-
Communicate breaking changes prominently:
- Email notifications to API consumers
- Dashboard warnings
- Response headers (Deprecation, Sunset)
- Changelog with migration guides
-
Provide migration tools:
- Automated migration scripts
- Backward compatibility layers
- Request/response transformers
-
Monitor version usage:
- Track requests by version
- Identify clients still using deprecated versions
- Contact high-volume users before deprecation
Authentication and Authorization
OAuth 2.0 for Third-Party Access
OAuth 2.0 enables secure delegated access without sharing passwords.
Authorization Code Flow (Most Secure):
# Step 1: Redirect user to authorization server
GET https://auth.example.com/authorize?
response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&scope=read_user,read_orders
&state=random_string_for_csrf_protection
Step 2: User grants permission, redirected back with code
https://yourapp.com/callback?code=AUTH_CODE&state=random_string
Step 3: Exchange code for access token
POST https://auth.example.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&redirect_uri=https://yourapp.com/callback
Response:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "refresh_token_here",
"scope": "read_user read_orders"
}
Step 4: Use access token for API requests
GET /api/users/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
JWT (JSON Web Tokens) for Stateless Auth
JWTs encode user identity and permissions in self-contained tokens.
JWT Structure:
// Header (algorithm and type)
{
"alg": "RS256",
"typ": "JWT"
}
// Payload (claims)
{
"sub": "user_id_123", // Subject (user ID)
"email": "user@example.com",
"name": "John Doe",
"roles": ["user", "admin"],
"iat": 1708012800, // Issued at (Unix timestamp)
"exp": 1708016400, // Expiration (Unix timestamp)
"iss": "https://auth.example.com" // Issuer
}
// Signature (prevents tampering)
RSASHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
privateKey
)
// Final JWT (base64url-encoded, dot-separated)
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyX2lkXzEyMyIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsIm5hbWUiOiJKb2huIERvZSIsInJvbGVzIjpbInVzZXIiLCJhZG1pbiJdLCJpYXQiOjE3MDgwMTI4MDAsImV4cCI6MTcwODAxNjQwMCwiaXNzIjoiaHR0cHM6Ly9hdXRoLmV4YW1wbGUuY29tIn0.signature_here
JWT Verification:
const jwt = require('jsonwebtoken');
const fs = require('fs');
// Load public key for verification
const publicKey = fs.readFileSync('public_key.pem');
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"
if (!token) {
return res.status(401).json({ error: 'Missing access token' });
}
jwt.verify(token, publicKey, { algorithms: ['RS256'] }, (err, payload) => {
if (err) {
// Token invalid, expired, or tampered
return res.status(403).json({ error: 'Invalid or expired token' });
}
// Token valid, attach payload to request
req.user = payload;
next();
});
}
// Protected route
app.get('/api/admin/users', authenticateToken, (req, res) => {
// Check authorization (roles)
if (!req.user.roles.includes('admin')) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
// User is admin, proceed
res.json({ users: [] });
});
API Keys for Server-to-Server
API keys provide simple authentication for server applications.
API Key Management:
// Generate secure API key
const crypto = require('crypto');
function generateApiKey() {
return crypto.randomBytes(32).toString('hex');
// Example: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
}
// Store hashed version in database
const hashedKey = crypto.createHash('sha256').update(apiKey).digest('hex');
await db.apiKeys.create({
key_hash: hashedKey,
user_id: userId,
permissions: ['read:users', 'write:orders'],
rate_limit: 1000, // requests per hour
expires_at: new Date('2027-01-01')
});
// Authenticate requests
app.use('/api', async (req, res, next) => {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'Missing API key' });
}
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
const keyRecord = await db.apiKeys.findOne({ key_hash: keyHash });
if (!keyRecord || keyRecord.expires_at < new Date()) {
return res.status(403).json({ error: 'Invalid or expired API key' });
}
req.apiKey = keyRecord;
next();
});
Rate Limiting and Throttling
Token Bucket Algorithm
Token bucket provides smooth rate limiting allowing bursts.
Implementation:
class TokenBucket {
constructor(capacity, refillRate) {
this.capacity = capacity; // Max tokens (burst size)
this.tokens = capacity; // Current tokens
this.refillRate = refillRate; // Tokens per second
this.lastRefill = Date.now();
}
refill() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000; // Seconds
const tokensToAdd = elapsed * this.refillRate;
this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
this.lastRefill = now;
}
consume(tokens = 1) {
this.refill();
if (this.tokens >= tokens) {
this.tokens -= tokens;
return true; // Request allowed
}
return false; // Rate limit exceeded
}
getWaitTime() {
this.refill();
if (this.tokens >= 1) return 0;
const tokensNeeded = 1 - this.tokens;
return Math.ceil(tokensNeeded / this.refillRate * 1000); // Milliseconds
}
}
// Rate limiter middleware
const buckets = new Map(); // userId -> TokenBucket
app.use('/api', (req, res, next) => {
const userId = req.user.id;
let bucket = buckets.get(userId);
if (!bucket) {
// 100 requests per minute with burst of 10
bucket = new TokenBucket(10, 100 / 60);
buckets.set(userId, bucket);
}
if (bucket.consume()) {
res.setHeader('X-RateLimit-Limit', 100);
res.setHeader('X-RateLimit-Remaining', Math.floor(bucket.tokens));
next();
} else {
const waitTime = bucket.getWaitTime();
res.setHeader('Retry-After', Math.ceil(waitTime / 1000));
res.status(429).json({
error: 'Rate limit exceeded',
retry_after_ms: waitTime
});
}
});
Distributed Rate Limiting with Redis
Single-server rate limiting doesn't work in multi-instance deployments.
Redis-Based Rate Limiter:
const Redis = require('ioredis');
const redis = new Redis();
async function checkRateLimit(userId, limit, window) {
const key = rate_limit:${userId};
const now = Date.now();
const windowStart = now - window;
// Sliding window log (ZSET with timestamps)
await redis.zremrangebyscore(key, 0, windowStart); // Remove old entries
const requestCount = await redis.zcard(key); // Count requests in window
if (requestCount < limit) {
await redis.zadd(key, now, ${now}-${Math.random()}); // Add request
await redis.expire(key, Math.ceil(window / 1000)); // Set expiration
return { allowed: true, remaining: limit - requestCount - 1 };
}
return { allowed: false, remaining: 0 };
}
// Middleware
app.use('/api', async (req, res, next) => {
const userId = req.user.id;
const result = await checkRateLimit(userId, 100, 60000); // 100/min
res.setHeader('X-RateLimit-Limit', 100);
res.setHeader('X-RateLimit-Remaining', result.remaining);
if (result.allowed) {
next();
} else {
res.setHeader('Retry-After', 60);
res.status(429).json({ error: 'Rate limit exceeded' });
}
});
API Documentation
OpenAPI/Swagger Specification
OpenAPI provides machine-readable API documentation enabling code generation and testing.
OpenAPI Example:
openapi: 3.0.0
info:
title: Example API
version: 1.0.0
description: API for managing users and orders
servers:
- url: https://api.example.com/v1
description: Production server
paths:
/users:
get:
summary: List users
operationId: listUsers
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: per_page
in: query
schema:
type: integer
default: 50
maximum: 100
responses:
'200':
description: Successful response
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
pagination:
$ref: '#/components/schemas/Pagination'
post:
summary: Create user
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- email
- password
properties:
email:
type: string
format: email
password:
type: string
minLength: 8
responses:
'201':
description: User created
content:
application/json:
schema:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
properties:
id:
type: string
format: uuid
email:
type: string
format: email
name:
type: string
created_at:
type: string
format: date-time
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: X-API-Key
security:
- ApiKeyAuth: []
Generate Interactive Docs:
# Swagger UI hosts interactive documentation
npm install swagger-ui-express
Express.js integration
const swaggerUi = require('swagger-ui-express');
const swaggerDocument = require('./openapi.json');
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
Access interactive docs at https://api.example.com/api-docs
Try requests directly from browser, see request/response examples
Conclusion
API design determines long-term system success more than implementation details—poorly designed APIs create irreversible technical debt (Stripe spends 10% of engineering time on breaking change migrations), while well-designed APIs enable ecosystem growth (Shopify's API economy processes $444B GMV, Twitter serves 500B+ monthly API requests). Systematic API design practices—resource-oriented REST conventions, GraphQL schema optimization with DataLoader batching, comprehensive versioning strategies, secure OAuth 2.0 authentication, distributed rate limiting, and OpenAPI documentation—reduce partner integration time 50-70%, decrease support tickets 60-80%, and improve developer satisfaction 10x through superior experience.
Production-proven patterns demonstrate concrete impact: GitHub's REST + GraphQL hybrid serves 100M+ developers with 99.95% uptime, Twilio processes 200B+ requests annually supporting 10M+ phone numbers, Stripe maintains 18-year backward compatibility record preventing ecosystem breakage. Core principles—predictable conventions over clever solutions, backward compatibility over rapid feature shipping, comprehensive documentation over minimal specs, proactive versioning over reactive breaking changes—separate APIs enabling business growth from APIs requiring constant firefighting.
API design proves neither one-time specification nor set-and-forget documentation but continuous engineering practice requiring OpenAPI specs (machine-readable contracts), versioning policies (6-12 month deprecation cycles), comprehensive testing (contract tests, backward compatibility validation), and developer relations (feedback incorporation, migration support). Teams embedding API design culture—reviewing endpoints during architecture design, testing backward compatibility in CI/CD, monitoring API metrics (error rates, latency, version distribution), conducting quarterly API health audits—prevent production incidents through proactive design rather than emergency hotfixes when partners break. Whether building internal microservice APIs or public developer platforms, treating API design as product requirement demanding systematic attention determines whether API becomes competitive advantage or liability preventing system evolution.
Related Articles
GraphQL API Design - Production Architecture and Best Practices for Scalable Systems
Master GraphQL API design covering schema design principles, resolver optimization, N+1 query prevention with DataLoader, authentication and authorization patterns, caching strategies, error handling, and production deployment for high-performance GraphQL systems.
Testing Strategies - Unit, Integration, and E2E Testing Best Practices for Production Quality
Comprehensive guide to testing strategies covering unit tests, integration tests, end-to-end testing, test-driven development, mocking patterns, testing pyramid, and production testing practices for reliable software delivery.
Monitoring and Observability - Production Systems Performance and Debugging at Scale
Master monitoring and observability covering metrics collection with Prometheus, distributed tracing with OpenTelemetry, log aggregation, alerting strategies, SLOs/SLIs, and production debugging techniques for reliable systems.
Written by StaticBlock Editorial
StaticBlock Editorial is a technical writer and software engineer specializing in web development, performance optimization, and developer tooling.