A 100-millisecond increase in API response time decreases conversion rates by 1% in e-commerce — and that is measurable, not anecdotal. At the infrastructure level, an unoptimized API wastes compute resources processing requests that could be served from cache, scanning database rows that a proper index would skip, and establishing database connections that connection pooling would reuse. API performance optimization is not premature optimization — it is the difference between an API that scales and one that requires constant firefighting as traffic grows.
This guide covers the systematic approach to API performance optimization: multi-layer caching strategy with Redis, cursor-based pagination, database query optimization, connection pooling, N+1 query elimination, response compression, CDN integration, asynchronous processing, and load balancing. Each section includes practical implementation patterns tested in production systems handling thousands of requests per second.
Where API Latency Comes From
Every API request travels through multiple latency layers. Understanding each layer's contribution directs optimization effort correctly.
A typical request latency breakdown for an unoptimized API:
| Layer | Unoptimized | Optimized | Technique |
|---|---|---|---|
| Network (round-trip) | 20-80ms | 5-20ms | CDN edge caching |
| TLS handshake | 10-30ms | 0ms (keepalive) | HTTP/2 persistent connections |
| API gateway | 5-15ms | 2-5ms | Gateway caching |
| Application processing | 10-30ms | 5-10ms | Efficient middleware |
| Database queries | 50-500ms | 2-20ms | Indexes, caching, pooling |
| External service calls | 100-1000ms | 5-50ms | Caching, circuit breakers |
Database queries are the dominant latency source in most APIs. The optimization impact hierarchy follows from this: caching frequently-accessed data, eliminating unnecessary queries (N+1), optimizing query execution (indexes), and scaling read capacity (replicas) — in that order.
Multi-Layer Caching Strategy
Application-Level Caching with Redis
Redis is the production standard for application-level API caching. A well-implemented cache can reduce database load by 80-90% for read-heavy APIs.
The pattern: check cache first, return if hit, query database if miss, store result in cache with appropriate TTL:
import { Redis } from 'ioredis';
import { promisify } from 'util';
const redis = new Redis({
host: process.env.REDIS_HOST,
port: 6379,
maxRetriesPerRequest: 3,
});
async function getProductWithCache(productId: string): Promise<Product> {
const cacheKey = `product:${productId}`;
// Try cache first
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Cache miss — query database
const product = await db.products.findById(productId);
if (!product) throw new NotFoundError(`Product ${productId} not found`);
// Store in cache with TTL based on data volatility
await redis.setex(cacheKey, 3600, JSON.stringify(product)); // 1 hour TTL
return product;
}
TTL Strategy by Data Type
TTL selection determines the tradeoff between cache hit rate and data freshness:
| Data Type | TTL | Rationale |
|---|---|---|
| User session data | 900s (15 min) | Security: expired sessions should fail |
| Product catalog | 3600s (1 hour) | Changes infrequently, high read volume |
| Category/navigation | 86400s (24 hours) | Very rarely changes |
| Search results | 300s (5 min) | Balances freshness with cost |
| Real-time inventory | 0 (no cache) | Accuracy required for purchases |
| Configuration data | 86400s (24 hours) | Changes require deployment anyway |
Stale-While-Revalidate Pattern
For data where slight staleness is acceptable, stale-while-revalidate provides optimal performance: return the cached value immediately while asynchronously refreshing it in the background:
async function getProductSWR(productId: string): Promise<Product> {
const cacheKey = `product:swr:${productId}`;
const staleCacheKey = `product:swr:stale:${productId}`;
const TTL = 300; // Fresh window: 5 minutes
const staleTTL = 3600; // Stale window: 1 hour
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached); // Return fresh cache immediately
}
// Check stale cache — return stale value and trigger background refresh
const stale = await redis.get(staleCacheKey);
if (stale) {
// Return stale value immediately
setImmediate(async () => {
const fresh = await db.products.findById(productId);
await redis.setex(cacheKey, TTL, JSON.stringify(fresh));
await redis.setex(staleCacheKey, staleTTL, JSON.stringify(fresh));
});
return JSON.parse(stale);
}
// No cache at all — fetch and store
const product = await db.products.findById(productId);
await redis.setex(cacheKey, TTL, JSON.stringify(product));
await redis.setex(staleCacheKey, staleTTL, JSON.stringify(product));
return product;
}
CDN Caching for Global Distribution
CDN caching reduces latency for geographically distributed users by serving responses from edge nodes close to the user, rather than routing all traffic to the origin server.
HTTP cache headers control CDN behavior:
// Static API response — safe to cache at CDN
router.get('/categories', async (req, res) => {
const categories = await getCategoriesWithCache();
res.set({
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
'Vary': 'Accept-Language',
'ETag': generateEtag(categories),
});
return res.json(categories);
});
// User-specific response — must not be cached at CDN
router.get('/orders', jwtValidation, async (req, res) => {
const orders = await getOrdersForUser(req.auth.sub);
res.set({
'Cache-Control': 'private, no-store',
});
return res.json(orders);
});
The public directive permits CDN caching. The private directive restricts caching to the user's browser. Never serve one user's private data from a CDN cache — the private directive is security-critical for authenticated responses.
Pagination: API Performance Optimization at Scale
Unpaginated list endpoints fail at scale. An endpoint that returns all orders will function in development with 50 records and fail in production with 5 million.
Cursor-Based vs. Offset Pagination
// Offset-based (degrades at scale)
GET /orders?page=5000&limit=20
// Database executes: SELECT * FROM orders OFFSET 99980 LIMIT 20
// The database scans 100,000 rows to return 20
// Cursor-based (constant performance)
GET /orders?after=ord_abc123&limit=20
// Database executes: SELECT * FROM orders WHERE id > 'ord_abc123' LIMIT 20
// The database scans only 20 rows — always
Offset pagination performs a table scan up to the offset. At page 5,000 with 20 items per page, the database reads 100,000 rows to return 20. The query time grows linearly with page number. Cursor pagination maintains constant query performance regardless of dataset size.
Cursor Pagination Implementation
interface PaginatedResponse<T> {
items: T[];
pagination: {
hasNextPage: boolean;
nextCursor: string | null;
totalCount?: number; // Omit for large datasets where COUNT(*) is expensive
};
}
async function listOrders(
customerId: string,
cursor: string | null,
limit = 20
): Promise<PaginatedResponse<Order>> {
const query: any = { customerId };
if (cursor) {
// Decode cursor (base64 of { id, createdAt })
const { id, createdAt } = JSON.parse(Buffer.from(cursor, 'base64').toString());
query.$or = [
{ createdAt: { $lt: new Date(createdAt) } },
{ createdAt: new Date(createdAt), _id: { $lt: id } },
];
}
// Fetch limit + 1 to determine if there's a next page
const orders = await Order.find(query)
.sort({ createdAt: -1, _id: -1 })
.limit(limit + 1);
const hasNextPage = orders.length > limit;
const items = hasNextPage ? orders.slice(0, limit) : orders;
const nextCursor = hasNextPage
? Buffer.from(JSON.stringify({
id: items[items.length - 1]._id,
createdAt: items[items.length - 1].createdAt,
})).toString('base64')
: null;
return { items, pagination: { hasNextPage, nextCursor } };
}
The limit + 1 trick determines whether a next page exists without a separate COUNT query. Fetch one more than requested; if you get it, there is a next page.
Database API Performance Optimization
Connection Pooling
Each new database connection has overhead: TCP handshake, authentication, and connection initialization. An API server that opens a new connection for every request wastes 10-50ms per request establishing connections.
Connection pooling maintains a pool of open connections reused across requests:
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
min: 2, // Minimum pool size (always open)
max: 20, // Maximum connections
idleTimeoutMillis: 30000, // Release idle connections after 30s
connectionTimeoutMillis: 2000, // Fail fast if pool is exhausted
allowExitOnIdle: false,
});
// Monitoring pool health
pool.on('connect', () => metrics.increment('db.pool.connection.created'));
pool.on('acquire', () => metrics.increment('db.pool.connection.acquired'));
pool.on('remove', () => metrics.increment('db.pool.connection.removed'));
For PostgreSQL in production, PgBouncer provides connection pooling at the proxy layer, enabling hundreds of application threads to share a small number of actual PostgreSQL connections (PostgreSQL handles ~100-200 concurrent connections efficiently; beyond that, performance degrades).
N+1 Query Elimination
The N+1 problem is the most common database performance issue in list-returning APIs. It occurs when fetching a list of records and then making individual queries for related data per record:
// N+1 problem: 1 query for orders + N queries for customers
const orders = await Order.find({ status: 'pending' }); // 1 query
const enriched = await Promise.all(
orders.map(async (order) => {
const customer = await Customer.findById(order.customerId); // N queries
return { ...order, customer };
})
);
// Solution: batch fetch customers in one query
const orders = await Order.find({ status: 'pending' });
const customerIds = [...new Set(orders.map(o => o.customerId))];
const customers = await Customer.find({ _id: { $in: customerIds } }); // 1 query
const customerMap = new Map(customers.map(c => [c.id, c]));
const enriched = orders.map(order => ({
...order,
customer: customerMap.get(order.customerId),
}));
The batch fetch pattern reduces N+1 queries to a constant 2 queries regardless of list size. For GraphQL APIs, DataLoader implements this pattern automatically by batching resolver calls within a single request.
Query Optimization and Indexing
Indexes are the highest-leverage database optimization for read-heavy APIs. A query that performs a full table scan on a 10-million-row table takes seconds. The same query with a correct composite index takes milliseconds.
Common index patterns for API performance optimization:
-- Paginated list by user and time (common pattern for activity feeds, order history)
CREATE INDEX CONCURRENTLY idx_orders_customer_created
ON orders (customer_id, created_at DESC);
-- Filtered search with sorting
CREATE INDEX CONCURRENTLY idx_products_category_price
ON products (category_id, price)
WHERE is_active = true; -- Partial index for active products only
-- Full-text search
CREATE INDEX CONCURRENTLY idx_products_search
ON products USING GIN (to_tsvector('english', name || ' ' || description));
EXPLAIN ANALYZE reveals whether queries are using indexes and where time is spent. Any query showing Seq Scan on a large table is a candidate for index addition.
Read Replicas for Read Scaling
Write operations must go to the primary database for consistency. Read operations can be distributed across replicas, linearly scaling read throughput:
import { Pool } from 'pg';
const primaryPool = new Pool({ connectionString: process.env.DATABASE_PRIMARY_URL });
const replicaPool = new Pool({ connectionString: process.env.DATABASE_REPLICA_URL });
// Direct writes to primary
async function createOrder(data: CreateOrderDto): Promise<Order> {
return primaryPool.query('INSERT INTO orders ...', [data]);
}
// Direct reads to replica
async function listOrders(customerId: string): Promise<Order[]> {
return replicaPool.query('SELECT * FROM orders WHERE customer_id = $1', [customerId]);
}
Read replicas are appropriate when read traffic significantly exceeds write traffic (common in e-commerce, content, and analytics APIs) and when slight read lag (typically <1 second) is acceptable.
Response Compression
Compression reduces transfer size and latency for clients on slower connections:
import compression from 'compression';
app.use(compression({
filter: (req, res) => {
// Don't compress responses smaller than 1KB
if (req.headers['x-no-compression']) return false;
return compression.filter(req, res);
},
threshold: 1024, // Only compress responses larger than 1KB
level: 6, // Brotli/gzip level (1-9; 6 is a good default)
}));
Gzip provides 60-70% size reduction for JSON responses. Brotli provides slightly better compression (~15% better than gzip) and is supported by all modern browsers. The compression overhead (typically 1-5ms) is well worth the bandwidth reduction for responses over 5KB.
Asynchronous Processing
Long-running operations — email sending, report generation, file processing, bulk imports — should not block API responses. The accepted pattern:
- API accepts the request and immediately returns
202 Acceptedwith a job ID - Background worker processes the job asynchronously
- Client polls the job status endpoint or receives a webhook on completion
// Endpoint: accepts request and enqueues job
router.post('/reports', jwtValidation, async (req, res) => {
const job = await reportQueue.add('generate-report', {
userId: req.auth.sub,
reportType: req.body.type,
filters: req.body.filters,
});
return res.status(202).json({
jobId: job.id,
status: 'queued',
statusUrl: `${req.baseUrl}/reports/jobs/${job.id}`,
});
});
// Endpoint: check job status
router.get('/reports/jobs/:jobId', jwtValidation, async (req, res) => {
const job = await reportQueue.getJob(req.params.jobId);
if (!job) return res.status(404).json({ error: 'Job not found' });
const state = await job.getState();
return res.json({
jobId: job.id,
status: state,
progress: job.progress,
result: state === 'completed' ? job.returnvalue : null,
error: state === 'failed' ? job.failedReason : null,
});
});
Bull (backed by Redis) is a production-grade Node.js queue implementation. For cross-language job queuing, Celery (Python), Sidekiq (Ruby), or cloud-native solutions (AWS SQS, Google Cloud Tasks) provide equivalent functionality.
Load Balancing
Horizontal scaling requires a load balancer to distribute traffic across multiple API instances. Round-robin distribution (each instance receives requests in sequence) works for stateless APIs. Least-connections distribution (next request goes to the instance with fewest active connections) performs better under variable request duration.
Health checks ensure the load balancer stops sending traffic to unhealthy instances immediately:
router.get('/health/ready', async (req, res) => {
try {
// Verify database connectivity
await pool.query('SELECT 1');
// Verify cache connectivity
await redis.ping();
return res.json({ status: 'ready' });
} catch (error) {
return res.status(503).json({
status: 'not ready',
reason: error.message,
});
}
});
router.get('/health/live', (req, res) => {
// Simple liveness check — is the process responding?
return res.json({ status: 'alive' });
});
The readiness probe (/health/ready) checks whether the instance can handle traffic. The liveness probe (/health/live) checks whether the process is alive. Kubernetes uses both: readiness gates traffic, liveness triggers pod restart.
Performance Monitoring and Alerting
Optimization without measurement is guesswork. The key performance indicators for API monitoring:
import prometheus from 'prom-client';
const httpRequestDuration = new prometheus.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests',
labelNames: ['method', 'route', 'status'],
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5],
});
const cacheHitRate = new prometheus.Counter({
name: 'cache_hits_total',
help: 'Cache hit count',
labelNames: ['cache_key_prefix'],
});
// Middleware to track request duration
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = (Date.now() - start) / 1000;
httpRequestDuration
.labels(req.method, req.route?.path || 'unknown', res.statusCode.toString())
.observe(duration);
});
next();
});
Alert thresholds:
- p95 latency > 500ms: investigate immediately
- Error rate > 1%: investigate immediately
- Cache hit rate < 70%: review cache key design and TTL values
- Database connection pool utilization > 80%: increase pool size or add read replicas
Optimization Summary
Applied in priority order, these techniques compound. A properly cached endpoint with cursor pagination, connection pooling, and N+1 elimination typically achieves:
- Baseline (unoptimized): 200-500ms response time
- After Redis caching: 5-20ms (cache hit path)
- After pagination fix: no timeout on large datasets
- After N+1 fix: 50-70% reduction in database query count
- After compression: 60-70% reduction in transfer size
- After CDN: 10-50ms for globally distributed users
API performance optimization is an ongoing practice, not a project with a completion date. As data grows, traffic patterns change, and new endpoints are added, the optimization cycle continues. The infrastructure described here — metrics collection, cache hit tracking, query plan monitoring, and load testing — provides the visibility needed to identify and address performance regressions before they reach end users.
Related Articles
MLOps Guide: Taking Machine Learning Models to Production [2026]
87% of machine learning models built by data science teams never reach production. The models work — they pass cross-validation, they score well on holdout sets, they demonstrate genuine predictive value. The problem is not the modeling. The problem is everything that happens between a notebook experiment and a reliable, monitored, production system. MLOps is the discipline that closes that gap. This guide covers the full MLOps stack: maturity levels, tooling choices (MLflow, DVC, Kubeflow
Read More: MLOps Guide: Taking Machine Learning Models to Production [2026]LLM Fine-Tuning Guide: Custom Model Training with LoRA and QLoRA [2026]
General-purpose LLMs are impressive. They can write code, summarize documents, answer questions, and translate between languages with reasonable accuracy. But "reasonable" is not good enough when your application requires consistent output format, domain-specific terminology, a particular tone, or behavior that the base model was never trained to exhibit. That gap is where fine-tuning matters. Fine-tuning updates a model's weights on your specific data, changing how the model behaves — not
Read More: LLM Fine-Tuning Guide: Custom Model Training with LoRA and QLoRA [2026]Computer Vision Applications: Object Detection, OCR, and Industrial AI [2026]
Computer vision has moved well past the research phase. The models are trained, the frameworks are mature, the hardware is accessible, and the use cases are generating measurable returns. What was a specialized capability requiring deep expertise in 2018 is now deployable infrastructure — if you know which component to reach for and where the real complexity lives. This guide covers computer vision applications across industrial, medical, logistics, and document processing domains. It expl
Read More: Computer Vision Applications: Object Detection, OCR, and Industrial AI [2026]
