HinterBuild logoHinterBuild
Backend Systems · 10 min read

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, author

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

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:

typescript
// 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:

python
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:

go
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

json
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:

typescript
// 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);
});
ApproachProsConsBest For
Query param ?fields=Simple, cacheableManual schema maintenanceREST APIs
GraphQLClient-driven, typedComplexity, N+1 riskMobile + web varied needs
Separate endpointsClear contractsMore endpoints to maintainMicroservices
gRPC with projectionsEfficient binaryLess browser-friendlyInternal 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

sql
-- 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

python
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 TypePerformance at ScaleJump to Page NConsistency
OffsetDegrades linearlyYesSnapshot issues
Keyset/cursorConstant timeNoStable under inserts
Seek (composite)Constant timePartialRequires 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

python
@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

python
@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),
    )
OperationSync ThresholdAsync Pattern
Database writeAlways syncTransaction in handler
Email/notificationsNever syncQueue + worker
Search indexingNever syncQueue + worker
Payment chargeSync (user waits)Idempotent handler
LLM generationStream or asyncSSE 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

go
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 TypeCache-ControlCDN
Public catalogpublic, max-age=300Yes
User-specificprivate, max-age=60No
Real-time datano-storeNo
Immutable assetspublic, max-age=31536000, immutableYes

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

typescript
// 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

typescript
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:

LayerTimeoutTool
Client → API30sLoad balancer idle timeout
API → Database5sstatement_timeout in PostgreSQL
API → External10shttpx/Go context timeout
API handler25sMiddleware 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.

FormatSerialize (10K objs, typical)Payload SizeBrowser Support
JSON (stdlib Python)380ms100%Native
orjson (Python)95ms95%Native
JSON (Go stdlib)42ms100%Native
Protocol Buffers15ms40%Requires codegen
MessagePack55ms60%Library needed

Recommendations

  • Python APIs: Use orjson response class in FastAPI
  • Go APIs: stdlib encoding/json is sufficient; consider jsoniter for hot paths
  • Internal services: gRPC with protobuf for 60% bandwidth reduction
  • Public REST: JSON with compression (gzip/brotli at load balancer)
python
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
# 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 "";
    }
}
OptimizationBandwidth ReductionLatency Impact (mobile)
Brotli compression60–80% on JSON200–500ms saved on 3G
HTTP/2 multiplexingEliminates connection overhead100–300ms on multi-call pages
Keep-alive connectionsAvoids TCP/TLS handshake50–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:

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