API Design Mistakes That Kill Performance: Fixes That Work
Eight API design mistakes that kill performance — N+1 calls, over-fetching, offset pagination, sync side effects — with Go, Python, and TypeScript fixes.
Muhammad Abdul Sami
· Updated · 10 min read
- APIs
- Performance
- Backend Systems
- PostgreSQL
- Caching
- Architecture
Table of Contents:
- Why API Design Drives Performance More Than Language
- Mistake 1: Chatty APIs and N+1 Requests
- Mistake 2: Over-Fetching and Under-Fetching
- Mistake 3: Missing or Broken Pagination
- Mistake 4: Synchronous Slow Operations
- Mistake 5: No Caching Headers or Strategy
- Mistake 6: Unbounded Queries and Missing Timeouts
- Mistake 7: Poor Serialization Choices
- API Performance Audit Checklist
- Mistake 8: Ignoring HTTP/2 and Compression
- Frequently Asked Questions
Why API Design Drives Performance More Than Language
Short answer: API design mistakes cause more production performance failures than choice of Go vs Python — a chatty frontend calling 20 endpoints will collapse under load regardless of runtime.
If you searched "API design mistakes performance", you likely have endpoints that work in development but degrade at scale: p99 latency spiking, database connections exhausted, and mobile clients timing out. At HinterBuild, our backend API engineering team finds that 70% of performance incidents trace to API contract design, not infrastructure.
Key Takeaways:
- N+1 API calls from clients multiply server load linearly with list size
- Over-fetching wastes bandwidth and serialization CPU on mobile clients
- Offset pagination on large tables causes full table scans — use keyset pagination
- Synchronous side effects (email, webhooks) in request handlers block threads
- Fix API contracts before scaling infrastructure — it's cheaper and more effective
This guide covers the eight API design mistakes that kill performance, with production fixes in Go, Python, and TypeScript. Each section shows the failure mode, why it degrades non-linearly with load, and the contract change that removes it.
Mistake 1: Chatty APIs and N+1 Requests
The most destructive pattern: a list endpoint returns IDs, and the client fetches each item individually.
The Problem
GET /orders → 50 order IDs GET /orders/1 → order details GET /orders/2 → order details ... (50 requests) GET /users/abc → user for order 1 GET /users/def → user for order 2 ... (50 more requests)
101 HTTP requests to render one page. At 1,000 concurrent users, that's 101,000 RPS — entirely preventable.
The Fix: Batch and Embed
Design endpoints that return related data in one response:
// Bad: returns only IDs
interface OrderListBad {
orders: { id: string }[];
}
// Good: embed related resources
interface OrderListGood {
orders: {
id: string;
total: number;
status: string;
user: { id: string; name: string }; // embedded
items: { sku: string; qty: number }[]; // embedded
}[];
}
Server-side, use a single JOIN or batched query — never loop:
async def get_orders_bad(limit: int):
orders = await db.fetch("SELECT id FROM orders LIMIT $1", limit)
result = []
for order in orders:
user = await db.fetchrow("SELECT * FROM users WHERE id = $1", order["user_id"])
items = await db.fetch("SELECT * FROM items WHERE order_id = $1", order["id"])
result.append({**order, "user": user, "items": items})
return result
# GOOD: 2 queries total
async def get_orders_good(limit: int):
orders = await db.fetch("""
SELECT o.*, u.name as user_name, u.email as user_email
FROM orders o
JOIN users u ON u.id = o.user_id
ORDER BY o.created_at DESC
LIMIT $1
""", limit)
order_ids = [o["id"] for o in orders]
items = await db.fetch(
"SELECT * FROM order_items WHERE order_id = ANY($1)", order_ids
)
items_by_order = {}
for item in items:
items_by_order.setdefault(item["order_id"], []).append(item)
return [{**o, "items": items_by_order.get(o["id"], [])} for o in orders]
Go implementation with pgx batch:
func (r *OrderRepo) ListWithDetails(ctx context.Context, limit int) ([]OrderDetail, error) {
rows, err := r.pool.Query(ctx, `
SELECT o.id, o.total, o.status, u.id, u.name
FROM orders o JOIN users u ON u.id = o.user_id
ORDER BY o.created_at DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var orders []OrderDetail
var orderIDs []string
for rows.Next() {
var o OrderDetail
rows.Scan(&o.ID, &o.Total, &o.Status, &o.User.ID, &o.User.Name)
orders = append(orders, o)
orderIDs = append(orderIDs, o.ID)
}
// Batch fetch items
itemRows, _ := r.pool.Query(ctx,
`SELECT order_id, sku, qty FROM order_items WHERE order_id = ANY($1)`, orderIDs)
// ... map items to orders
return orders, nil
}
For system design at scale, chatty APIs are the first thing we eliminate in architecture reviews.
Mistake 2: Over-Fetching and Under-Fetching
Over-fetching returns fields the client doesn't need. Under-fetching forces additional requests. Both kill performance on mobile and at scale.
Over-Fetching Example
GET /users/123
{
"id": "123",
"email": "user@example.com",
"name": "Jane Doe",
"bio": "... 2000 characters ...",
"preferences": { /* 50 fields */ },
"billing_address": { /* full address */ },
"audit_log": [ /* 500 entries */ ],
"internal_notes": "VIP customer"
}
Mobile client needed only name and avatar. You serialized 50KB and exposed internal fields.
The Fix: Field Selection and Projections
Support sparse fieldsets via query parameter:
// GET /users/123?fields=id,name,avatar_url
app.get("/users/:id", async (req, res) => {
const fields = (req.query.fields as string)?.split(",") ?? DEFAULT_USER_FIELDS;
const allowed = fields.filter(f => ALLOWED_USER_FIELDS.has(f));
const user = await userService.getProjected(req.params.id, allowed);
res.json(user);
});
| Approach | Pros | Cons | Best For |
|---|---|---|---|
Query param ?fields= | Simple, cacheable | Manual schema maintenance | REST APIs |
| GraphQL | Client-driven, typed | Complexity, N+1 risk | Mobile + web varied needs |
| Separate endpoints | Clear contracts | More endpoints to maintain | Microservices |
| gRPC with projections | Efficient binary | Less browser-friendly | Internal services |
For public REST APIs, ?fields= covers 90% of use cases without GraphQL overhead.
Mistake 3: Missing or Broken Pagination
Unpaginated list endpoints work with 100 rows and fail catastrophically at 1M.
Offset Pagination Problem
-- Page 1000 with LIMIT 50 OFFSET 49950 -- PostgreSQL scans and discards 49,950 rows SELECT * FROM events ORDER BY created_at DESC LIMIT 50 OFFSET 49950;
At page 10,000, this query takes seconds and hammers the database. See PostgreSQL performance secrets for why.
The Fix: Keyset (Cursor) Pagination
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class PaginatedEvents(BaseModel):
items: list[Event]
next_cursor: Optional[str]
has_more: bool
async def list_events(cursor: Optional[str] = None, limit: int = 50) -> PaginatedEvents:
if cursor:
cursor_time = decode_cursor(cursor) # base64 encoded timestamp+id
rows = await db.fetch("""
SELECT * FROM events
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3
""", cursor_time.ts, cursor_time.id, limit + 1)
else:
rows = await db.fetch("""
SELECT * FROM events
ORDER BY created_at DESC, id DESC
LIMIT $1
""", limit + 1)
has_more = len(rows) > limit
items = rows[:limit]
next_cursor = encode_cursor(items[-1]) if has_more else None
return PaginatedEvents(items=items, next_cursor=next_cursor, has_more=has_more)
| Pagination Type | Performance at Scale | Jump to Page N | Consistency |
|---|---|---|---|
| Offset | Degrades linearly | Yes | Snapshot issues |
| Keyset/cursor | Constant time | No | Stable under inserts |
| Seek (composite) | Constant time | Partial | Requires sort index |
Always paginate. Default limit=50, max limit=100. Return next_cursor, never expose raw offset to clients on large tables. The PostgreSQL documentation on LIMIT and OFFSET is explicit that skipped rows are still computed by the server — offset cost is real work, not a pointer jump.
One subtlety: keyset pagination needs a composite index matching the ORDER BY — here (created_at DESC, id DESC). Without it the planner falls back to a sort and you lose the constant-time property. Check EXPLAIN (ANALYZE, BUFFERS) for an Index Scan rather than Sort before shipping.
Mistake 4: Synchronous Slow Operations
Blocking request handlers on email, webhooks, PDF generation, or LLM inference exhausts connection pools and thread pools.
The Problem
@app.post("/signup")
async def signup(req: SignupRequest):
user = await create_user(req)
await send_welcome_email(user) # 2-5 seconds
await sync_to_crm(user) # 1-3 seconds
await index_in_search(user) # 500ms
await notify_slack(user) # 300ms
return user # Client waited 8+ seconds
The Fix: Return Fast, Process Async
@app.post("/signup", status_code=201)
async def signup(req: SignupRequest):
user = await create_user(req)
await queue.publish("user.created", {
"user_id": user.id,
"email": user.email,
})
return user # Client gets response in < 100ms
# Worker processes side effects
async def handle_user_created(event: dict):
user = await get_user(event["user_id"])
await asyncio.gather(
send_welcome_email(user),
sync_to_crm(user),
index_in_search(user),
)
| Operation | Sync Threshold | Async Pattern |
|---|---|---|
| Database write | Always sync | Transaction in handler |
| Email/notifications | Never sync | Queue + worker |
| Search indexing | Never sync | Queue + worker |
| Payment charge | Sync (user waits) | Idempotent handler |
| LLM generation | Stream or async | SSE stream or job queue |
Build async pipelines with data pipelines & integrations patterns — SQS, Kafka, or Redis streams.
Mistake 5: No Caching Headers or Strategy
APIs without cache headers force clients and CDNs to re-fetch identical responses every time.
Cache-Control Patterns
func listPublicProducts(w http.ResponseWriter, r *http.Request) {
products := fetchProducts()
// Public catalog: cache 5 minutes at CDN
w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=60")
w.Header().Set("ETag", computeETag(products))
if r.Header.Get("If-None-Match") == computeETag(products) {
w.WriteHeader(http.StatusNotModified)
return
}
json.NewEncoder(w).Encode(products)
}
func getUserProfile(w http.ResponseWriter, r *http.Request) {
profile := fetchProfile(r.Context())
// Private user data: no CDN cache, client may cache briefly
w.Header().Set("Cache-Control", "private, max-age=60")
json.NewEncoder(w).Encode(profile)
}
| Resource Type | Cache-Control | CDN |
|---|---|---|
| Public catalog | public, max-age=300 | Yes |
| User-specific | private, max-age=60 | No |
| Real-time data | no-store | No |
| Immutable assets | public, max-age=31536000, immutable | Yes |
Combine HTTP caching with Redis for server-side cache-aside. At 10M user scale, caching typically eliminates the majority of database reads for read-heavy endpoints. The semantics of max-age, stale-while-revalidate, and private are specified in RFC 9111 (HTTP Caching) — CDNs follow the spec closely, so a wrong directive silently disables caching rather than erroring.
Mistake 6: Unbounded Queries and Missing Timeouts
APIs that accept arbitrary filters without limits enable denial-of-service — accidental or intentional.
Dangerous Patterns
// Accepts ANY filter — full table scan
app.get("/search", async (req, res) => {
const { q, sort, filters } = req.query;
const results = await db.query(
`SELECT * FROM products WHERE name ILIKE '%${q}%' ORDER BY ${sort}`
); // SQL injection + unbounded scan
res.json(results);
});
The Fix: Validation, Limits, Timeouts
import { z } from "zod";
const SearchSchema = z.object({
q: z.string().min(2).max(100),
sort: z.enum(["name", "price", "created_at"]).default("created_at"),
order: z.enum(["asc", "desc"]).default("desc"),
limit: z.coerce.number().min(1).max(100).default(20),
cursor: z.string().optional(),
});
app.get("/search", async (req, res) => {
const params = SearchSchema.parse(req.query);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const results = await searchService.search(params, controller.signal);
res.json(results);
} catch (err) {
if (err.name === "AbortError") {
return res.status(504).json({ error: "Search timeout" });
}
throw err;
} finally {
clearTimeout(timeout);
}
});
Apply timeouts at every layer:
| Layer | Timeout | Tool |
|---|---|---|
| Client → API | 30s | Load balancer idle timeout |
| API → Database | 5s | statement_timeout in PostgreSQL |
| API → External | 10s | httpx/Go context timeout |
| API handler | 25s | Middleware deadline |
Monitor timeout rates in observability & monitoring — rising 504s indicate downstream degradation.
Mistake 7: Poor Serialization Choices
Serialization is CPU-bound and runs on every request. Default choices leave performance on the table.
Serialization Performance
Numbers below are illustrative of typical relative ordering on a single core; measure with your own payload shapes. The orjson benchmarks in the project README are the reference point for the Python figures.
| Format | Serialize (10K objs, typical) | Payload Size | Browser Support |
|---|---|---|---|
| JSON (stdlib Python) | 380ms | 100% | Native |
| orjson (Python) | 95ms | 95% | Native |
| JSON (Go stdlib) | 42ms | 100% | Native |
| Protocol Buffers | 15ms | 40% | Requires codegen |
| MessagePack | 55ms | 60% | Library needed |
Recommendations
- Python APIs: Use
orjsonresponse class in FastAPI - Go APIs: stdlib
encoding/jsonis sufficient; considerjsoniterfor hot paths - Internal services: gRPC with protobuf for 60% bandwidth reduction
- Public REST: JSON with compression (
gzip/brotliat load balancer)
from fastapi import FastAPI from fastapi.responses import ORJSONResponse app = FastAPI(default_response_class=ORJSONResponse)
Compare framework defaults in FastAPI vs Gin vs Express.
For streaming LLM responses, use newline-delimited JSON or SSE — never buffer complete responses before sending.
API Performance Audit Checklist
Run this audit on every API before scaling:
Request Patterns
- No N+1 client call patterns (batch endpoints exist)
- Related data embedded or available via
?include= - Field selection supported for large objects
- All list endpoints paginated (keyset, not offset)
Server Behavior
- No synchronous slow operations in handlers
- Database queries use indexes (check EXPLAIN ANALYZE)
- Connection pooling configured (PgBouncer)
- Timeouts on all external calls and queries
Caching
- Cache-Control headers on appropriate endpoints
- ETag/If-None-Match for conditional requests
- Redis cache-aside for hot database reads
- CDN configured for static and public API responses
Serialization & Transport
- Fast JSON library (orjson, not stdlib Python)
- Compression enabled at load balancer
- Response payloads under 100KB for mobile endpoints
- Streaming for large or real-time responses
Deploy on cloud infrastructure with autoscaling triggered by p95 latency, not just CPU.
Mistake 8: Ignoring HTTP/2 and Compression
Modern clients support HTTP/2 multiplexing and brotli compression — but APIs deployed behind misconfigured load balancers leave both disabled.
The Problem
HTTP/1.1 clients open 6 parallel connections per domain. Mobile apps making 20 API calls instantiate connection overhead 20×. Without compression, a 50KB JSON payload ships as 50KB on the wire instead of ~8KB with brotli.
The Fix
# nginx: enable HTTP/2 and brotli at load balancer
server {
listen 443 ssl http2;
brotli on;
brotli_types application/json application/javascript text/plain;
brotli_comp_level 6;
gzip on;
gzip_types application/json;
gzip_min_length 256;
location /api/ {
proxy_pass http://api_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
| Optimization | Bandwidth Reduction | Latency Impact (mobile) |
|---|---|---|
| Brotli compression | 60–80% on JSON | 200–500ms saved on 3G |
| HTTP/2 multiplexing | Eliminates connection overhead | 100–300ms on multi-call pages |
| Keep-alive connections | Avoids TCP/TLS handshake | 50–150ms per reused connection |
Enable HTTP/2 at the load balancer even when backend servers speak HTTP/1.1 — ALB and nginx terminate HTTP/2 from clients and multiplex to backends over persistent connections. Multiplexing is defined in RFC 9113 (HTTP/2); the practical effect is that 20 small API calls share one TCP+TLS handshake instead of queueing behind a six-connection browser limit.
Combine transport optimizations with PostgreSQL query tuning for end-to-end latency wins. Transport fixes help mobile clients; database fixes help everyone.
Frequently Asked Questions
What is the biggest API design mistake for performance?
Chatty APIs — clients making N+1 requests to render a single view. One endpoint returning embedded related data eliminates orders of magnitude of load.
Should I use GraphQL to fix over-fetching?
GraphQL solves over-fetching but introduces N+1 resolver risk and caching complexity. For most teams, REST with ?fields= and embedded resources is simpler and faster to optimize.
Is offset pagination ever acceptable?
Yes, for admin interfaces with small datasets (< 10K rows) where users need to jump to arbitrary pages. For public APIs and large tables, always use keyset/cursor pagination.
How do I find N+1 queries in production?
Enable SQL query logging with request IDs. If a single API request generates 10+ identical-pattern queries, you have N+1. Tools: OpenTelemetry database spans, Django Debug Toolbar (dev), pg_stat_statements.
Does API versioning affect performance?
Only if you maintain multiple serialization code paths indefinitely. Version via URL prefix (/v2/) and deprecate old versions on a schedule. Don't serve v1 and v2 from the same handler with branching logic.
How do I performance-test API design?
Use k6 or wrk with realistic payloads — not hello-world. Simulate mobile clients making the actual call patterns your frontend uses. Compare p95 before and after contract changes.
What HTTP status codes affect caching?
200 with Cache-Control for cacheable responses. 304 Not Modified when ETag matches. Never cache 4xx/5xx. Use 202 Accepted for async operations.
When should I switch from REST to gRPC?
When internal service-to-service traffic exceeds 5K RPS and payload size or serialization cost matters. Keep REST/JSON for public and browser-facing APIs.
Conclusion
API design mistakes that kill performance are fixable without rewriting your entire stack. Start with the highest-impact changes:
- Eliminate chatty N+1 client patterns with batch endpoints
- Implement keyset pagination on all list endpoints
- Move slow operations to async queues
- Add caching headers and Redis cache-aside
- Enforce query timeouts and input validation
Language and infrastructure matter — but API contract design is the lever most teams ignore. Fix contracts first, then scale pods.
At HinterBuild, we audit and rebuild APIs for production performance:
- Backend API Engineering
- Observability & Monitoring
- Cloud Infrastructure & DevOps
- Data Pipelines & Integrations
Schedule a consultation for an API performance audit.
Free consultation
Book a free consultation call on API design & backend performance
30-minute call with the HinterBuild team. Discuss your project, architecture questions, or next steps — no obligation.
Book a meeting
Keep reading
Related articles
Webhook Design for Reliability at Scale: Production Patterns
Webhook Design for Reliability at Scale guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable, reliable.
Read post
PostgreSQL Performance Secrets Developers Miss (Guide)
PostgreSQL Performance Secrets Developers Miss (Guide) guidance for engineers: compare architecture choices, avoid failure modes, and ship a.
Read post
gRPC vs REST vs GraphQL: How to Choose the Right API
Learn grpc vs rest vs graphql through concrete architecture trade-offs, failure modes, rollout controls, and production measurement practices.
Read post
Webhook Design for AI Pipelines: Reliability Patterns for
Build reliable webhook systems for AI pipelines with retry logic, idempotency, and validation. Production patterns from processing 50K+ AI webhooks daily.
Read post
