smaple.tr
API performance

API Performance Optimization: Caching, Pagination, and Load Balancing [2026]

Mehmet Kurtipek
July 25, 2026
13 min read
API performance
caching
Redis
pagination
load balancing
CDN

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:

  1. API accepts the request and immediately returns 202 Accepted with a job ID
  2. Background worker processes the job asynchronously
  3. 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

August 11, 2026

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]
August 10, 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]
August 9, 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]