Advanced
25 min
Full Guide
API Best Practices
Learn rate limiting, caching, security, versioning, and API design patterns
Building Production-Ready API Clients
Writing API calls is easy—building robust, maintainable, and performant API integrations requires following best practices. This guide covers essential patterns for production applications.
1. Rate Limiting Handling
Many APIs limit how many requests you can make. Handle this gracefully:
// Rate limiter class
class RateLimiter {
constructor(maxRequests, windowMs) {
this.maxRequests = maxRequests;
this.windowMs = windowMs;
this.requests = [];
}
async waitForSlot() {
const now = Date.now();
// Remove old requests outside the window
this.requests = this.requests.filter(
time => now - time < this.windowMs
);
if (this.requests.length >= this.maxRequests) {
// Wait until oldest request expires
const oldestRequest = this.requests[0];
const waitTime = this.windowMs - (now - oldestRequest) + 10;
console.log(`Rate limited, waiting ${waitTime}ms`);
await new Promise(resolve => setTimeout(resolve, waitTime));
return this.waitForSlot();
}
this.requests.push(now);
}
}
// API client with rate limiting
class RateLimitedClient {
constructor(baseUrl, requestsPerMinute = 60) {
this.baseUrl = baseUrl;
this.limiter = new RateLimiter(requestsPerMinute, 60000);
}
async request(endpoint, options = {}) {
await this.limiter.waitForSlot();
const response = await fetch(`${this.baseUrl}${endpoint}`, options);
// Handle 429 Too Many Requests
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : 60000;
console.log(`Rate limited by server, waiting ${waitTime}ms`);
await new Promise(resolve => setTimeout(resolve, waitTime));
return this.request(endpoint, options);
}
return response.json();
}
}
// Usage
const api = new RateLimitedClient('https://api.example.com', 30);
const data = await api.request('/users');
2. Response Caching
// Simple in-memory cache
class CacheManager {
constructor(defaultTTL = 60000) {
this.cache = new Map();
this.defaultTTL = defaultTTL;
}
set(key, value, ttl = this.defaultTTL) {
this.cache.set(key, {
value,
expiry: Date.now() + ttl
});
}
get(key) {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiry) {
this.cache.delete(key);
return null;
}
return entry.value;
}
has(key) {
return this.get(key) !== null;
}
delete(key) {
this.cache.delete(key);
}
clear() {
this.cache.clear();
}
}
// API client with caching
class CachedApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.cache = new CacheManager();
}
getCacheKey(endpoint, options) {
return `${options.method || 'GET'}:${endpoint}`;
}
async get(endpoint, options = {}) {
const cacheKey = this.getCacheKey(endpoint, { method: 'GET' });
// Check cache first
const cached = this.cache.get(cacheKey);
if (cached && !options.forceRefresh) {
console.log('Cache hit:', endpoint);
return cached;
}
// Fetch fresh data
console.log('Cache miss:', endpoint);
const response = await fetch(`${this.baseUrl}${endpoint}`);
const data = await response.json();
// Cache the response
const ttl = options.cacheTTL || 60000; // 1 minute default
this.cache.set(cacheKey, data, ttl);
return data;
}
// Invalidate cache after mutations
async post(endpoint, body) {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
// Invalidate related cache entries
this.invalidateRelated(endpoint);
return response.json();
}
invalidateRelated(endpoint) {
// Example: POST to /users invalidates GET /users cache
const basePath = endpoint.split('/').slice(0, 2).join('/');
for (const key of this.cache.cache.keys()) {
if (key.includes(basePath)) {
this.cache.delete(key);
}
}
}
}
// SWR (Stale-While-Revalidate) pattern
async function fetchWithSWR(key, fetchFn, ttl = 60000) {
const cache = window.__swrCache || (window.__swrCache = new Map());
const cached = cache.get(key);
if (cached) {
// Return stale data immediately
const isStale = Date.now() > cached.staleAt;
if (isStale) {
// Revalidate in background
fetchFn().then(data => {
cache.set(key, {
data,
staleAt: Date.now() + ttl
});
});
}
return cached.data;
}
// No cache - fetch and store
const data = await fetchFn();
cache.set(key, {
data,
staleAt: Date.now() + ttl
});
return data;
}
3. Request Deduplication
// Prevent duplicate concurrent requests
class RequestDeduplicator {
constructor() {
this.pending = new Map();
}
async dedupe(key, fetchFn) {
// If request already in progress, return the same promise
if (this.pending.has(key)) {
console.log('Deduping request:', key);
return this.pending.get(key);
}
// Start new request
const promise = fetchFn().finally(() => {
this.pending.delete(key);
});
this.pending.set(key, promise);
return promise;
}
}
// Usage
const deduper = new RequestDeduplicator();
async function getUser(id) {
return deduper.dedupe(`user-${id}`, () =>
fetch(`/api/users/${id}`).then(r => r.json())
);
}
// These all share the same request!
const [user1, user2, user3] = await Promise.all([
getUser(123),
getUser(123),
getUser(123)
]);
// Only ONE API call is made
4. Request Batching
// Batch multiple requests into one
class RequestBatcher {
constructor(batchFn, options = {}) {
this.batchFn = batchFn;
this.delay = options.delay || 10;
this.maxBatchSize = options.maxBatchSize || 100;
this.queue = [];
this.timeout = null;
}
add(item) {
return new Promise((resolve, reject) => {
this.queue.push({ item, resolve, reject });
if (this.queue.length >= this.maxBatchSize) {
this.flush();
} else if (!this.timeout) {
this.timeout = setTimeout(() => this.flush(), this.delay);
}
});
}
async flush() {
clearTimeout(this.timeout);
this.timeout = null;
if (this.queue.length === 0) return;
const batch = this.queue.splice(0, this.maxBatchSize);
const items = batch.map(b => b.item);
try {
const results = await this.batchFn(items);
batch.forEach((request, index) => {
request.resolve(results[index]);
});
} catch (error) {
batch.forEach(request => {
request.reject(error);
});
}
}
}
// Usage - batch user lookups
const userBatcher = new RequestBatcher(
async (userIds) => {
// Single API call for all users
const response = await fetch('/api/users/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: userIds })
});
return response.json();
},
{ delay: 50, maxBatchSize: 100 }
);
// These get batched into one request!
const users = await Promise.all([
userBatcher.add(1),
userBatcher.add(2),
userBatcher.add(3)
]);
5. Security Best Practices
// Security patterns for API clients
// 1. Never expose secrets in client code
// ❌ Bad
const API_KEY = 'sk_live_abc123'; // In client bundle!
// ✅ Good - use environment variables and server proxy
// Client calls your server, server calls external API
async function callSecureApi() {
return fetch('/api/proxy/external-service');
}
// 2. Validate responses before using
function validateUser(data) {
if (typeof data !== 'object' || data === null) {
throw new Error('Invalid user response');
}
if (typeof data.id !== 'number') {
throw new Error('Invalid user ID');
}
if (typeof data.email !== 'string') {
throw new Error('Invalid email');
}
return data;
}
// 3. Sanitize data before displaying
function sanitizeHtml(dirty) {
const element = document.createElement('div');
element.textContent = dirty;
return element.innerHTML;
}
// 4. Use Content Security Policy headers
// Prevent XSS from injected scripts
// 5. Protect against CSRF
async function securePost(endpoint, data) {
// Get CSRF token from meta tag or cookie
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
return fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify(data),
credentials: 'same-origin' // Include cookies
});
}
// 6. Implement proper CORS handling
// Server should set appropriate headers:
// Access-Control-Allow-Origin: https://yourdomain.com
// Access-Control-Allow-Credentials: true
// Access-Control-Allow-Methods: GET, POST, PUT, DELETE
6. API Versioning
// Handle API versioning gracefully
class VersionedApiClient {
constructor(baseUrl, version = 'v1') {
this.baseUrl = baseUrl;
this.version = version;
}
// Prepend version to endpoints
getUrl(endpoint) {
return `${this.baseUrl}/${this.version}${endpoint}`;
}
async get(endpoint) {
return fetch(this.getUrl(endpoint)).then(r => r.json());
}
// Upgrade version
upgradeVersion(newVersion) {
this.version = newVersion;
}
}
// Feature detection pattern
class AdaptiveApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.version = null;
}
async detectVersion() {
try {
// Try latest version first
const v2Response = await fetch(`${this.baseUrl}/v2/health`);
if (v2Response.ok) {
this.version = 'v2';
return;
}
} catch {}
this.version = 'v1'; // Fallback
}
async request(endpoint, options) {
if (!this.version) {
await this.detectVersion();
}
return fetch(`${this.baseUrl}/${this.version}${endpoint}`, options);
}
}
// Backward compatibility transformers
const responseTransformers = {
v1: {
user: (data) => ({
id: data.user_id,
name: data.user_name,
email: data.email_address
})
},
v2: {
user: (data) => data // v2 uses standard naming
}
};
async function getUser(id) {
const response = await api.get(`/users/${id}`);
const transformer = responseTransformers[api.version].user;
return transformer(response);
}
7. Logging & Monitoring
// Request/response logging for debugging
class LoggingApiClient {
constructor(baseUrl, options = {}) {
this.baseUrl = baseUrl;
this.logging = options.logging ?? true;
this.metrics = [];
}
async request(endpoint, options = {}) {
const startTime = performance.now();
const requestId = crypto.randomUUID();
if (this.logging) {
console.group(`🌐 API Request: ${requestId}`);
console.log('Endpoint:', endpoint);
console.log('Method:', options.method || 'GET');
if (options.body) console.log('Body:', options.body);
}
try {
const response = await fetch(`${this.baseUrl}${endpoint}`, options);
const duration = performance.now() - startTime;
const data = await response.json();
// Record metrics
this.metrics.push({
requestId,
endpoint,
method: options.method || 'GET',
status: response.status,
duration,
timestamp: new Date().toISOString()
});
if (this.logging) {
console.log('Status:', response.status);
console.log('Duration:', `${duration.toFixed(2)}ms`);
console.log('Response:', data);
console.groupEnd();
}
return data;
} catch (error) {
const duration = performance.now() - startTime;
this.metrics.push({
requestId,
endpoint,
method: options.method || 'GET',
status: 0,
error: error.message,
duration,
timestamp: new Date().toISOString()
});
if (this.logging) {
console.error('Error:', error.message);
console.groupEnd();
}
throw error;
}
}
// Get performance metrics
getMetrics() {
const total = this.metrics.length;
const errors = this.metrics.filter(m => m.status === 0 || m.status >= 400).length;
const avgDuration = this.metrics.reduce((sum, m) => sum + m.duration, 0) / total;
return {
totalRequests: total,
errorCount: errors,
errorRate: (errors / total * 100).toFixed(2) + '%',
averageDuration: avgDuration.toFixed(2) + 'ms',
requests: this.metrics
};
}
// Send metrics to analytics
async reportMetrics() {
const metrics = this.getMetrics();
await fetch('/api/analytics/api-metrics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metrics)
});
}
}
8. Complete Production API Client
// Production-ready API client combining all best practices
class ProductionApiClient {
constructor(config) {
this.baseUrl = config.baseUrl;
this.version = config.version || 'v1';
this.timeout = config.timeout || 30000;
// Initialize helpers
this.cache = new CacheManager(config.cacheTTL);
this.rateLimiter = new RateLimiter(
config.rateLimit || 60,
60000
);
this.deduper = new RequestDeduplicator();
// Auth
this.token = null;
}
setToken(token) {
this.token = token;
}
getHeaders() {
const headers = {
'Content-Type': 'application/json',
'X-API-Version': this.version
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
return headers;
}
async request(method, endpoint, options = {}) {
const url = `${this.baseUrl}/${this.version}${endpoint}`;
const cacheKey = `${method}:${url}`;
// Check cache for GET requests
if (method === 'GET' && !options.skipCache) {
const cached = this.cache.get(cacheKey);
if (cached) return cached;
}
// Dedupe concurrent identical requests
return this.deduper.dedupe(cacheKey, async () => {
// Wait for rate limit slot
await this.rateLimiter.waitForSlot();
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
options.timeout || this.timeout
);
try {
const response = await fetch(url, {
method,
headers: this.getHeaders(),
body: options.body ? JSON.stringify(options.body) : undefined,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
const error = new Error('Request failed');
error.status = response.status;
error.data = await response.json().catch(() => ({}));
throw error;
}
const data = await response.json();
// Cache successful GET responses
if (method === 'GET') {
this.cache.set(cacheKey, data, options.cacheTTL);
}
return data;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
});
}
// Convenience methods
get(endpoint, options) {
return this.request('GET', endpoint, options);
}
post(endpoint, body, options) {
return this.request('POST', endpoint, { ...options, body });
}
put(endpoint, body, options) {
return this.request('PUT', endpoint, { ...options, body });
}
patch(endpoint, body, options) {
return this.request('PATCH', endpoint, { ...options, body });
}
delete(endpoint, options) {
return this.request('DELETE', endpoint, options);
}
}
// Usage
const api = new ProductionApiClient({
baseUrl: 'https://api.example.com',
version: 'v2',
timeout: 15000,
rateLimit: 100,
cacheTTL: 60000
});
api.setToken(authToken);
const users = await api.get('/users');
const newUser = await api.post('/users', { name: 'John' });
💡 API Client Checklist
- ✓ Error handling - Graceful failures with user feedback
- ✓ Retries - Exponential backoff for transient errors
- ✓ Timeouts - Prevent hanging requests
- ✓ Caching - Reduce redundant requests
- ✓ Rate limiting - Respect API limits
- ✓ Request deduplication - Prevent duplicate calls
- ✓ Authentication - Secure token handling
- ✓ Logging - Debug visibility and monitoring