API Performance and Reliability Best Practices
By Oleksandr Andrushchenko — Published on
API performance is not limited to producing the lowest possible response time. A production API must remain responsive under normal traffic, sudden spikes, slow downstream services, partial infrastructure failures, and unexpected client behavior.
API reliability means requests behave predictably even when individual components fail. Achieving both performance and reliability requires clear latency targets, efficient database access, caching, timeouts, retries, rate limits, idempotency, asynchronous processing, load testing, and continuous monitoring.
Table of Contents
- API Performance vs Reliability
- Define Measurable Service Objectives
- Reduce Request Latency
- Improve Database Performance
- Use Caching Carefully
- Move Expensive Work Asynchronously
- Protect Downstream Dependencies
- Design Idempotent Operations
- Control Traffic and Resource Consumption
- Implement Graceful Degradation
- Design for Horizontal Scaling
- Use Effective Health Checks
- Build Production Observability
- Load Test Realistic Scenarios
- Common Mistakes
- Production Checklist
- Conclusion
- Key Takeaway
- More Articles to Read
API Performance vs Reliability
Performance describes how efficiently an API processes requests. Common measurements include latency, throughput, CPU usage, memory consumption, database load, and network bandwidth.
Reliability describes whether the API continues producing correct and predictable results over time. It includes availability, failure recovery, error rates, data consistency, dependency isolation, and behavior during traffic spikes.
An API can be fast but unreliable. For example, an endpoint may respond in 40 milliseconds under normal traffic but fail completely when a downstream payment service becomes slow. Another API may remain available but return every request in five seconds, making it technically operational but practically unusable.
Production design must optimize both dimensions together.
| Area | Performance Goal | Reliability Goal |
|---|---|---|
| Latency | Reduce response time | Keep latency predictable during failures |
| Database | Reduce query cost | Avoid overload and connection exhaustion |
| Dependencies | Reduce network waiting time | Prevent cascading failures |
| Scaling | Increase throughput | Maintain availability during traffic spikes |
| Data processing | Complete work efficiently | Avoid duplicate or lost operations |
Define Measurable Service Objectives
Performance cannot be improved effectively without measurable targets. Average response time alone is insufficient because a small number of very slow requests can remain hidden.
Useful API indicators include:
- p50 latency: The median request duration
- p95 latency: The duration below which 95% of requests complete
- p99 latency: The duration below which 99% of requests complete
- Error rate: The percentage of failed requests
- Throughput: Requests processed per second
- Availability: The percentage of time the service is usable
- Saturation: CPU, memory, connection pool, thread pool, or queue utilization
A practical service-level objective might state:
99.9% of API requests should succeed each month.
95% of GET requests should complete within 300 milliseconds.
99% of POST requests should complete within 800 milliseconds.
Targets should reflect business requirements. A reporting API may tolerate several seconds, while checkout, authentication, and search endpoints usually require much lower latency.
Reduce Request Latency
Request latency is the combined time spent in routing, authentication, application logic, database queries, serialization, network calls, and infrastructure queues. Optimization should begin with measurements rather than assumptions.
Avoid Unnecessary Work
Each synchronous request should perform only the work required to produce its response. Avoid recalculating static configuration, loading unused relationships, serializing hidden fields, or calling downstream services whose results are not needed.
def get_order(order_id: str) -> dict:
# Load only the columns required by the API response.
order = order_repository.find_summary(order_id)
return {
"id": order.id,
"status": order.status,
"total": order.total,
}
Returning a summary projection is often more efficient than loading a complete domain graph containing items, events, payments, audit records, and customer metadata.
Optimize Request and Response Payloads
Large payloads increase serialization cost, network transfer time, client memory use, and parsing time. APIs should return only useful fields and paginate collections.
Useful techniques include:
- Remove internal or duplicated fields
- Support field selection for large resources
- Compress sufficiently large text responses
- Avoid embedding large binary files in JSON
- Use object storage and signed URLs for downloads
GET /api/orders/ORD-1001?fields=id,status,total
Compression is useful for large JSON responses, but compressing very small payloads can cost more CPU than the saved bandwidth justifies.
Use Connection Pooling
Creating a new database or HTTP connection for every request adds handshake latency and increases resource usage. Applications should reuse connections through bounded pools.
import httpx
# Reuse one client so TCP and TLS connections remain pooled.
http_client = httpx.Client(
timeout=httpx.Timeout(3.0),
limits=httpx.Limits(
max_connections=100,
max_keepalive_connections=20,
),
)
Pool sizes should match application concurrency and dependency capacity. Oversized pools can overwhelm databases, while undersized pools create waiting queues inside the application.
Improve Database Performance
Database access is frequently the largest source of API latency. Efficient queries usually provide larger improvements than micro-optimizing application code.
Avoid N+1 Queries
An N+1 query occurs when an application loads one collection and then executes another query for every item.
# Inefficient: one query loads orders,
# then one additional query runs for every order.
orders = order_repository.find_recent()
for order in orders:
order.customer = customer_repository.find(order.customer_id)
The problem can be solved with joins, batch loading, eager loading, or a dedicated projection query.
-- Load the required order and customer fields in one query.
SELECT
o.id,
o.status,
o.total,
c.id AS customer_id,
c.name AS customer_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at >= :created_after
ORDER BY o.created_at DESC
LIMIT 100;
Use Correct Indexes
Indexes should support real filtering, joining, and sorting patterns. An index is useful only when it matches how queries access data.
-- Supports filtering by account and sorting recent orders.
CREATE INDEX idx_orders_account_created_at
ON orders (account_id, created_at DESC);
Indexes also have costs. Every additional index consumes storage and increases insert, update, and delete work. Query plans should be inspected before adding indexes blindly.
Paginate Large Result Sets
An endpoint should not load or return thousands of records in one request. Large responses increase database execution time, application memory, network use, and client processing time.
Cursor pagination is generally more stable than large offset pagination:
-- Fetch the next page after the last item from the previous page.
SELECT id, status, created_at
FROM orders
WHERE account_id = :account_id
AND (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 50;
The cursor should contain enough information to preserve a deterministic order, such as both created_at and id.
Use Caching Carefully
Caching reduces repeated database work and shortens response times, but it introduces invalidation, consistency, and memory-management concerns.
Common caching layers include:
- Client caching: Browser or mobile application caches
- CDN caching: Geographically distributed public responses
- Reverse-proxy caching: Responses cached before reaching the application
- Application caching: Redis, Memcached, or in-memory data
- Database caching: Buffer pools and query-related internal caches
def get_product(product_id: str) -> dict:
cache_key = f"product:{product_id}"
# Return cached data when available.
cached_product = cache.get(cache_key)
if cached_product is not None:
return cached_product
product = product_repository.find(product_id)
response = serialize_product(product)
# Use a bounded TTL to prevent permanently stale data.
cache.set(cache_key, response, ttl_seconds=300)
return response
Caches should define:
- What data may be cached
- How cache keys are constructed
- How long entries remain valid
- How updates invalidate stale entries
- What happens when the cache is unavailable
Private or user-specific responses must not be stored in shared caches unless the cache key safely includes the complete authorization context.
Move Expensive Work Asynchronously
Requests should not wait for work that can safely finish later. Email delivery, report generation, image processing, webhook delivery, analytics updates, and large imports are common asynchronous workloads.
Instead of holding the connection open:
POST /api/reports
HTTP/1.1 202 Accepted
Location: /api/report-jobs/job-123
{
"job_id": "job-123",
"status": "pending"
}
The API persists the job, publishes a message, and returns immediately. A worker processes the task independently.
def create_report(request: ReportRequest) -> dict:
# Persist the job before publishing work.
job = report_job_repository.create(
account_id=request.account_id,
status="pending",
)
# Workers can process this message independently.
queue.publish({
"job_id": job.id,
"account_id": request.account_id,
})
return {
"job_id": job.id,
"status": job.status,
}
Asynchronous systems must handle retries, duplicate messages, dead-letter queues, job visibility, and idempotent processing.
Protect Downstream Dependencies
An API often depends on databases, payment providers, identity services, search clusters, object storage, and other APIs. A slow dependency can consume all available workers and cause a cascading failure.
Timeouts
Every network request should have an explicit timeout. Infinite or excessively long timeouts allow requests to occupy threads, connections, or event-loop tasks indefinitely.
response = http_client.get(
"https://inventory.example.com/items/ITEM-123",
# Stop waiting when the dependency exceeds the latency budget.
timeout=2.0,
)
Timeouts should fit within the complete request budget. An API with a 500-millisecond target cannot safely make three sequential downstream calls with five-second timeouts.
Retries with Backoff and Jitter
Retries can recover from temporary failures, but immediate retries may multiply traffic against an already overloaded service.
import random
import time
def retry_request(operation, max_attempts: int = 3):
for attempt in range(max_attempts):
try:
return operation()
except TemporaryDependencyError:
if attempt == max_attempts - 1:
raise
# Exponential backoff reduces pressure on the dependency.
base_delay = 0.2 * (2 ** attempt)
# Jitter prevents many clients retrying simultaneously.
delay = base_delay + random.uniform(0, 0.1)
time.sleep(delay)
Retries should normally be limited to transient failures such as timeouts, connection resets, rate limits, and selected 5xx responses. Invalid requests and most 4xx responses should not be retried.
Circuit Breakers
A circuit breaker temporarily stops calls to a dependency after repeated failures. This prevents every incoming request from waiting for the same unhealthy service.
Closed: Requests flow normally.
Open: Requests fail fast or use a fallback.
Half-open: A limited number of requests test whether the dependency recovered.
Circuit breakers are useful when a dependency failure would otherwise exhaust connection pools or request workers.
Design Idempotent Operations
Clients may retry requests after timeouts without knowing whether the original operation succeeded. Without idempotency, retries can create duplicate payments, orders, or reservations.
POST /api/payments
Idempotency-Key: 75e9545a-b932-4489-a6b1-b0730c261f18
The server stores the key together with the operation result. Repeated requests with the same key return the original result instead of creating another payment.
def create_payment(request: PaymentRequest, idempotency_key: str):
existing = idempotency_repository.find(idempotency_key)
if existing is not None:
# Return the previously completed operation.
return existing.response
payment = payment_service.charge(request)
# Persist the key and result atomically when possible.
idempotency_repository.save(
key=idempotency_key,
response=serialize_payment(payment),
)
return serialize_payment(payment)
Idempotency keys should be scoped to the correct account or customer and retained long enough to cover realistic retry periods.
Control Traffic and Resource Consumption
Rate limiting protects APIs from accidental loops, abusive consumers, credential attacks, and unexpected demand. Limits can be applied by account, user, API key, IP address, endpoint, or operation cost.
Systems should also define:
- Maximum request body size
- Maximum page size
- Maximum query complexity
- Maximum concurrent requests per consumer
- Maximum queue depth
- Maximum execution duration
HTTP/1.1 429 Too Many Requests
Retry-After: 30
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 30
Limits should fail predictably and provide enough information for consumers to retry responsibly.
Implement Graceful Degradation
Not every dependency is essential to every response. When a recommendation service fails, an e-commerce product page may still return product details without recommendations.
def get_product_page(product_id: str) -> dict:
product = product_repository.find(product_id)
try:
recommendations = recommendation_client.get(product_id)
except DependencyUnavailableError:
# Keep the core response available without optional data.
recommendations = []
return {
"product": serialize_product(product),
"recommendations": recommendations,
}
Fallbacks should never hide failures affecting correctness. Returning stale pricing, authorization data, or inventory may be more dangerous than returning an explicit error.
Design for Horizontal Scaling
Stateless API instances can be added or removed behind a load balancer. Session data, files, distributed locks, and shared state should not depend on one application process.
A horizontally scalable API typically stores state in external systems:
- Relational or NoSQL databases for persistent data
- Redis or another shared store for sessions and coordination
- Object storage for uploaded files
- Queues for asynchronous tasks
- Centralized logging and metrics systems
Autoscaling should consider more than CPU usage. Useful signals include request concurrency, response latency, queue depth, database connections, and load-balancer request count.
Use Effective Health Checks
Health checks allow orchestrators and load balancers to determine whether an instance should receive traffic.
Liveness checks answer whether the process is still running. They should not fail because an optional dependency is temporarily unavailable.
Readiness checks answer whether the instance can safely receive traffic. They may verify configuration, database connectivity, required caches, or startup completion.
def readiness() -> tuple[dict, int]:
checks = {
"database": database.is_available(),
"queue": queue.is_available(),
}
is_ready = all(checks.values())
return {
"status": "ready" if is_ready else "not_ready",
"checks": checks,
}, 200 if is_ready else 503
Health endpoints should be fast and should not execute expensive queries. Aggressive checks against dependencies can create additional load during incidents.
Build Production Observability
Optimization without observability becomes guesswork. Logs, metrics, and traces should explain where time is spent and why requests fail.
Important metrics include:
- Request rate by endpoint and status code
- p50, p95, and p99 latency
- Database query duration
- Cache hit and miss rates
- Queue age and depth
- Dependency timeout and retry counts
- Connection pool utilization
- CPU, memory, and worker saturation
Structured logs should include consistent identifiers:
{
"level": "error",
"request_id": "req-71d9",
"trace_id": "trace-90ac",
"account_id": "ACC-100",
"method": "POST",
"path": "/api/orders",
"status_code": 503,
"duration_ms": 1842,
"dependency": "inventory-service",
"error": "dependency_timeout"
}
Distributed tracing connects work across API gateways, application services, databases, queues, and downstream APIs. It is especially useful when total latency is distributed across several components.
Load Test Realistic Scenarios
Load tests should represent actual traffic patterns rather than repeatedly calling one simple endpoint. Include reads, writes, authentication, database contention, cache misses, large payloads, and dependency latency.
Useful testing types include:
- Load testing: Verify expected production traffic
- Stress testing: Find the system's breaking point
- Spike testing: Simulate sudden traffic increases
- Soak testing: Run sustained traffic to reveal leaks and degradation
- Failure testing: Introduce slow or unavailable dependencies
from locust import HttpUser, between, task
class ApiUser(HttpUser):
# Simulate realistic pauses between user actions.
wait_time = between(1, 3)
@task(4)
def list_orders(self):
self.client.get("/api/v1/orders?limit=50")
@task(1)
def create_order(self):
self.client.post(
"/api/v1/orders",
json={
"customer_id": "CUS-100",
"items": [
{"product_id": "PROD-10", "quantity": 1}
],
},
headers={
"Idempotency-Key": "load-test-order-key",
},
)
Test results should be compared against latency objectives, error budgets, database capacity, and dependency limits.
Common Mistakes
Optimizing before measuring. Development effort may target serialization while slow database queries account for most latency.
Using average latency only. Averages can hide severe p95 and p99 degradation.
Retrying every failure. Uncontrolled retries increase traffic and can turn a small dependency issue into a complete outage.
Missing network timeouts. Requests may consume all available workers while waiting for an unhealthy service.
Caching without an invalidation strategy. Fast stale responses can be worse than slower correct responses.
Allowing unlimited result sets. One request can consume excessive memory, database time, and bandwidth.
Making every operation synchronous. Clients wait for emails, reports, analytics, and other work that could run asynchronously.
Scaling application instances without protecting the database. More API workers can create more database connections and worsen an outage.
Using shallow health checks only. A process may be alive while unable to serve real requests.
Testing only successful traffic. Production reliability depends on behavior during failures, timeouts, retries, and overload.
Production Checklist
- Define latency, availability, and error-rate objectives
- Track p50, p95, and p99 latency by endpoint
- Profile slow database queries and application paths
- Add indexes based on real query patterns
- Prevent N+1 database queries
- Paginate all potentially large collections
- Reuse database and HTTP connections through bounded pools
- Configure explicit timeouts for every dependency
- Retry only transient failures with backoff and jitter
- Use circuit breakers for unstable dependencies
- Implement idempotency for retryable write operations
- Move slow nonessential work to queues
- Protect endpoints with rate and concurrency limits
- Define maximum body, page, and query sizes
- Cache only when invalidation and privacy rules are clear
- Support graceful degradation for optional features
- Keep API instances stateless where possible
- Separate liveness and readiness checks
- Collect structured logs, metrics, and distributed traces
- Run load, spike, soak, and failure tests before production incidents
Conclusion
High-performance APIs are built by reducing unnecessary work, optimizing database access, reusing connections, controlling payload size, caching repeated reads, and moving expensive operations outside the synchronous request path.
Reliable APIs add another layer of protection through explicit timeouts, bounded retries, circuit breakers, idempotency, traffic controls, graceful degradation, health checks, horizontal scaling, and production observability.
The strongest architecture does not assume that databases, networks, clients, and downstream services will always behave correctly. It limits failure impact, preserves critical functionality, and provides enough telemetry to identify problems before they become prolonged outages.
Key Takeaway
Optimize APIs around measurable latency goals, protect every limited resource, isolate dependency failures, and design retries, caching, scaling, and asynchronous processing as coordinated parts of one reliability strategy.
Comments (0)