Skip links

API Design Patterns That Scale

Every API starts simple. A handful of endpoints, a few hundred requests per day, one or two client applications. Then it grows. New consumers appear — a mobile app, a partner integration, an internal analytics dashboard. Request volume goes from hundreds to hundreds of thousands. The data model evolves. And suddenly the design decisions you made in week one are either carrying you forward or dragging you under.

Article Overview

API Design Patterns That Scale

13 sections · Reading flow

01
REST vs. GraphQL: The Wrong Question
02
Resource Design: The Foundation Everything…
03
Pagination: The Pattern That Breaks Everything…
04
Versioning: There Are No Good Options, Only…
05
Error Handling: Your API's Second Most…
06
Rate Limiting: Protecting Your API and Your…
07
Authentication and Authorization Patterns
08
Caching Strategy
09
Request Validation: Fail Fast, Fail Clearly
10
Idempotency: The Pattern That Saves Everything
11
The Architecture That Scales
12
Health Checks and Graceful Degradation
13
Documentation as a First-Class Citizen

HARBOR SOFTWARE · Engineering Insights

At Harbor Software, we’ve designed and maintained APIs for everything from e-commerce platforms handling 50,000 daily orders to real-time feed processing systems ingesting millions of documents per day. What follows is not theory. These are the patterns that survived contact with production, and the trade-offs we learned to make when choosing between them.

REST vs. GraphQL: The Wrong Question

The internet is full of “REST vs. GraphQL” debates, and most of them miss the point entirely. The question isn’t which paradigm is better. The question is: what are the access patterns of your consumers, and which paradigm serves them with the least friction?

REST works best when: You have well-defined resources with predictable access patterns, your consumers are diverse (browsers, mobile apps, third-party integrations), you need aggressive HTTP caching, or you’re building a public API where simplicity and discoverability matter.

GraphQL works best when: Your consumers need flexible data fetching (different screens need different subsets of the same entities), you’re building a frontend-for-backend API where the client team controls both sides, or your data model is deeply relational and consumers frequently need to traverse relationships.

On a recent project — a SaaS platform with a React dashboard — we started with REST and migrated to GraphQL after six months. The migration wasn’t because REST was wrong. It was because the dashboard kept needing new combinations of data that required either N+1 REST calls or custom aggregation endpoints that were proliferating uncontrollably. GraphQL let the frontend team compose exactly the data they needed without backend changes for every new screen.

Conversely, for our NimbusFeed RSS processing system, we use pure REST. The consumers are automated pipelines, not interactive UIs. They always need the same data shape. Caching is critical for performance. GraphQL would have added complexity with zero benefit.

Resource Design: The Foundation Everything Else Rests On

Bad resource design is the gift that keeps on giving — every downstream problem (performance, versioning, documentation) traces back to it. Here are the principles we follow:

Resources Are Nouns, Not Verbs

This is the most-cited REST principle and also the most-violated. Your endpoints should represent things, not actions.

// Bad: verbs as endpoints
POST /api/sendEmail
POST /api/calculateShipping
GET  /api/getUserOrders

// Good: resources with HTTP method semantics
POST /api/emails              // Create (send) an email
GET  /api/shipping/estimates   // Get shipping estimates  
GET  /api/users/123/orders     // Get orders for user 123

But this principle has limits. Some operations genuinely don’t map to CRUD on a resource. “Run a security scan” isn’t creating, reading, updating, or deleting anything — it’s triggering a process. For these cases, we use a “command resource” pattern:

POST /api/security-scans
{
  "target": "https://example.com",
  "type": "full",
  "notify": ["team@harbor.software"]
}

// Returns:
{
  "id": "scan-a1b2c3",
  "status": "queued",
  "estimatedCompletion": "2026-03-30T14:30:00Z",
  "_links": {
    "self": "/api/security-scans/scan-a1b2c3",
    "results": "/api/security-scans/scan-a1b2c3/results"
  }
}

The scan itself becomes a resource you can poll, cancel, or reference later. This gives you all the benefits of RESTful design (discoverability, cacheability, standard HTTP semantics) while handling non-CRUD operations cleanly.

Consistent Naming Is a Feature

Naming consistency across your entire API surface reduces cognitive load for every consumer. We enforce these rules across all Harbor projects:

  • Plural nouns for collections: /api/users, /api/products, /api/orders
  • Kebab-case for multi-word resources: /api/security-scans, not /api/securityScans
  • Nested resources for ownership: /api/users/123/orders (not /api/orders?userId=123), but only one level deep
  • Query parameters for filtering, sorting, pagination: /api/products?category=electronics&sort=-createdAt&page=2
  • camelCase for JSON field names: { "createdAt": "...", "orderTotal": 4999 }

Pagination: The Pattern That Breaks Everything If You Get It Wrong

Every list endpoint needs pagination. No exceptions. Even if your dataset is small today, it won’t be forever, and retrofitting pagination into an existing API is a breaking change.

Offset-Based Pagination

The simplest approach and the one most developers reach for first:

GET /api/products?page=3&limit=20

{
  "data": [...],
  "pagination": {
    "page": 3,
    "limit": 20,
    "total": 1847,
    "totalPages": 93
  }
}

This works fine until your dataset gets large or changes frequently. The problem: if a new item is inserted between page fetches, a consumer will either miss an item or see a duplicate. For a product catalog that changes once a day, this is acceptable. For a real-time feed that changes every second, it’s not.

The other problem is performance. OFFSET 10000 LIMIT 20 in most databases means “fetch 10,020 rows, throw away 10,000 of them.” At scale, this becomes a database bottleneck.

Cursor-Based Pagination

For large or frequently-changing datasets, cursor-based pagination is almost always the right choice:

GET /api/feed/articles?limit=20&after=eyJpZCI6MTIzNH0=

{
  "data": [...],
  "cursors": {
    "before": "eyJpZCI6MTIxNX0=",
    "after": "eyJpZCI6MTIzNH0=",
    "hasNext": true,
    "hasPrevious": true
  }
}

The cursor is an opaque token (usually a base64-encoded composite of the sort key and ID). The database query uses WHERE id > cursor_id LIMIT 20 instead of OFFSET, which performs consistently regardless of how deep into the dataset you are.

In NimbusFeed, our RSS processing system, cursor-based pagination reduced our p99 latency for article listing from 2.3 seconds to 47 milliseconds on a dataset of 12 million articles. That’s not an optimization — that’s the difference between usable and unusable.

Keyset Pagination for Complex Sorts

When you need to paginate on a non-unique field (like publishedAt), simple cursor pagination breaks because multiple rows can have the same sort value. The solution is a composite cursor:

// Cursor encodes both the sort value and the unique ID
const cursor = Buffer.from(
  JSON.stringify({ publishedAt: '2026-03-29T10:00:00Z', id: 'art_1234' })
).toString('base64');

// Database query uses both for deterministic ordering
const query = `
  SELECT * FROM articles
  WHERE (published_at, id) < ($1, $2)
  ORDER BY published_at DESC, id DESC
  LIMIT 20
`;

Versioning: There Are No Good Options, Only Trade-Offs

API versioning is one of those problems where every approach has significant downsides. Here's what we've tried and what we recommend:

URL Path Versioning

GET /api/v1/products/123
GET /api/v2/products/123

Pros: dead simple, highly visible, easy to route at the load balancer level, easy for consumers to understand. Cons: implies the entire API changed between versions (when usually only a few endpoints did), makes it tempting to maintain multiple complete API versions simultaneously.

Header Versioning

GET /api/products/123
Accept: application/vnd.harbor.v2+json

Pros: cleaner URLs, version is metadata about the request rather than part of the resource path. Cons: harder to test (can't just paste a URL in a browser), harder to route, consumers frequently forget the header and get unexpected behavior.

What We Actually Do

URL path versioning for major breaking changes (which should be rare — ideally once every 2-3 years), combined with additive-only changes for everything else. Adding a new field to a response is not a breaking change. Adding a new optional query parameter is not a breaking change. Removing a field, renaming a field, or changing a field's type — those are breaking changes that warrant a version bump.

// v1 response - original
{
  "id": 123,
  "name": "Widget",
  "price": 29.99
}

// v1 response after additive change - NOT breaking
{
  "id": 123,
  "name": "Widget",
  "price": 29.99,
  "currency": "USD",          // new field
  "priceFormatted": "$29.99"   // new field
}

// v2 response - breaking change (price structure changed)
{
  "id": 123,
  "name": "Widget",
  "price": {
    "amount": 2999,
    "currency": "USD",
    "formatted": "$29.99"
  }
}

We also maintain a deprecation timeline. When a v2 is released, v1 gets a 12-month deprecation window with Sunset and Deprecation headers on every response.

Error Handling: Your API's Second Most Important Feature

After the happy path, error handling is the feature that most affects developer experience. A well-designed error response saves consumers hours of debugging. A poorly-designed one generates support tickets.

// The standard we use across all Harbor APIs
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "The request body contains invalid fields.",
    "details": [
      {
        "field": "email",
        "code": "INVALID_FORMAT",
        "message": "Must be a valid email address.",
        "received": "not-an-email"
      },
      {
        "field": "age",
        "code": "OUT_OF_RANGE",
        "message": "Must be between 0 and 150.",
        "received": -5
      }
    ],
    "requestId": "req_abc123",
    "documentation": "https://docs.harbor.software/errors/VALIDATION_FAILED"
  }
}

Every error includes: a machine-readable code (not just the HTTP status), a human-readable message, field-level details for validation errors, a request ID for support correlation, and a documentation link. The requestId alone has probably saved us hundreds of hours of support time — when a consumer reports an issue, we can trace the exact request through our entire system.

Rate Limiting: Protecting Your API and Your Consumers

Rate limiting isn't just about protecting your servers. It's about giving consumers predictable behavior and preventing one misbehaving client from degrading the experience for everyone else.

// Rate limit headers we include on every response
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1711800000
Retry-After: 30  // only on 429 responses

We use a sliding window algorithm implemented in Redis. The key insight: rate limits should be per-consumer, not global. And different endpoints should have different limits. A GET /api/products endpoint can handle much more traffic than a POST /api/orders endpoint that triggers payment processing.

// Rate limit configuration (per-consumer, per-endpoint)
const rateLimits = {
  'GET /api/products': { window: '1m', max: 300 },
  'GET /api/products/:id': { window: '1m', max: 600 },
  'POST /api/orders': { window: '1m', max: 30 },
  'POST /api/auth/login': { window: '5m', max: 10 },  // tight limit
  'default': { window: '1m', max: 100 },
};

Authentication and Authorization Patterns

For server-to-server APIs, we use API keys with scoped permissions. For user-facing APIs, we use short-lived JWTs with refresh tokens. The pattern that's worked best for us:

// API key with explicit scopes
{
  "key": "hbr_live_a1b2c3d4e5f6",
  "scopes": ["products:read", "orders:read", "orders:write"],
  "rateLimit": "premium",
  "expiresAt": "2027-03-30T00:00:00Z"
}

// Middleware checks both authentication and authorization
async function authorize(req: Request, requiredScope: string) {
  const apiKey = req.headers['x-api-key'];
  const keyData = await keyStore.validate(apiKey);
  
  if (!keyData) {
    throw new ApiError('UNAUTHORIZED', 'Invalid API key', 401);
  }
  
  if (!keyData.scopes.includes(requiredScope)) {
    throw new ApiError(
      'FORBIDDEN',
      `This API key does not have the '${requiredScope}' permission.`,
      403
    );
  }
  
  req.consumer = keyData;
}

Key prefixes (hbr_live_ vs hbr_test_) are a small detail that pays massive dividends. They make it immediately obvious in logs whether a request is hitting production or test data, and they let you grep for accidentally-committed keys in codebases.

Caching Strategy

HTTP caching is REST's superpower, and most APIs underutilize it. Proper cache headers can reduce your server load by 60-80% for read-heavy APIs.

// For data that changes rarely (product catalog)
Cache-Control: public, max-age=300, stale-while-revalidate=60
ETag: "product-123-v47"

// For data that's personal but stable (user profile)
Cache-Control: private, max-age=60
ETag: "user-456-v12"

// For data that must always be fresh (order status)
Cache-Control: no-cache
ETag: "order-789-v3"  // still use ETag for conditional requests

The stale-while-revalidate directive is especially powerful. It tells caches: "serve the stale version immediately while fetching a fresh one in the background." Users get instant responses, and your server handles revalidation asynchronously.

Request Validation: Fail Fast, Fail Clearly

Every request should be validated before any business logic executes. We use JSON Schema for request validation because it's declarative, language-agnostic, and generates documentation automatically.

// JSON Schema for product creation
const createProductSchema = {
  type: 'object',
  required: ['name', 'price', 'categoryId'],
  properties: {
    name: { type: 'string', minLength: 1, maxLength: 200 },
    price: { type: 'integer', minimum: 0, description: 'Price in cents' },
    categoryId: { type: 'string', pattern: '^cat_[a-zA-Z0-9]{8}$' },
    description: { type: 'string', maxLength: 5000 },
    tags: {
      type: 'array',
      items: { type: 'string', minLength: 1 },
      maxItems: 20,
      uniqueItems: true,
    },
  },
  additionalProperties: false,
};

The additionalProperties: false is crucial. Without it, consumers can send fields you don't expect, and those fields silently disappear. When they later add that field to their read logic expecting it to be there, they get mysterious bugs. Rejecting unknown fields forces consumers to only send what you actually process.

Idempotency: The Pattern That Saves Everything

Network failures happen. Clients retry. Without idempotency, a retried POST /api/orders creates a duplicate order. The solution: idempotency keys.

POST /api/orders
Idempotency-Key: ord_req_a1b2c3d4

// Server logic:
async function createOrder(req: Request) {
  const idempotencyKey = req.headers['idempotency-key'];
  
  // Check if we've already processed this request
  const existing = await idempotencyStore.get(idempotencyKey);
  if (existing) {
    return existing.response;  // Return the original response
  }
  
  // Process the order
  const order = await orderService.create(req.body);
  
  // Store the response for future duplicate requests
  await idempotencyStore.set(idempotencyKey, {
    response: order,
    createdAt: new Date(),
    expiresAt: addHours(new Date(), 24),
  });
  
  return order;
}

We store idempotency records for 24 hours and include a hash of the request body. If a client sends the same idempotency key with a different body, we return a 422 error — that's a client bug, not a retry.

The Architecture That Scales

After building APIs across a dozen projects, the architecture pattern that's proven most resilient is what we call the "layered diamond":

  1. Gateway layer: Rate limiting, authentication, request logging, CORS
  2. Validation layer: Schema validation, input sanitization, permission checks
  3. Business logic layer: Domain operations, orchestration, event emission
  4. Data access layer: Database queries, cache reads/writes, external API calls
  5. Response layer: Serialization, pagination metadata, cache headers, HATEOAS links

Each layer only talks to adjacent layers. The business logic layer never constructs HTTP responses. The data access layer never checks permissions. This separation makes testing straightforward — you can unit test business logic without standing up an HTTP server, and integration test the full stack without mocking the database.

Health Checks and Graceful Degradation

Every API needs a health check endpoint, but most implementations are either too simple (always returns 200) or too complex (checks every dependency and fails if any is unhealthy). The pattern that works is a tiered health check:

GET /api/health

// Shallow check: "Is the process running and accepting connections?"
{
  "status": "healthy",
  "version": "2.4.1",
  "uptime": 847293
}

GET /api/health/deep

// Deep check: "Are all dependencies reachable?"
{
  "status": "degraded",
  "version": "2.4.1",
  "checks": {
    "database": { "status": "healthy", "latency_ms": 3 },
    "redis": { "status": "healthy", "latency_ms": 1 },
    "search_index": { "status": "unhealthy", "error": "connection refused" },
    "email_service": { "status": "healthy", "latency_ms": 45 }
  }
}

The shallow check is what load balancers and orchestrators should poll. The deep check is what your monitoring system calls. Importantly, the deep check returns "degraded" rather than "unhealthy" when non-critical dependencies are down. The search index being unavailable shouldn't cause the load balancer to pull the instance out of rotation — it should trigger an alert while the API continues serving requests that don't need search.

This leads to graceful degradation at the API level. When a non-critical dependency is down, the API should continue functioning for endpoints that don't depend on it, and return a specific error for endpoints that do. We implement this with a circuit breaker pattern per dependency:

const searchBreaker = new CircuitBreaker(searchClient, {
  failureThreshold: 5,
  resetTimeout: 30000,  // Try again after 30s
});

// In the endpoint handler
async function searchProducts(req, res) {
  try {
    const results = await searchBreaker.execute(() => 
      searchClient.search(req.query.q)
    );
    return res.json({ data: results });
  } catch (err) {
    if (err instanceof CircuitOpenError) {
      return res.status(503).json({
        error: {
          code: 'SEARCH_UNAVAILABLE',
          message: 'Search is temporarily unavailable. Browse products by category instead.',
          fallback_url: '/api/products?category=all'
        }
      });
    }
    throw err;
  }
}

Documentation as a First-Class Citizen

An API without documentation is an API that generates support tickets. We generate OpenAPI specs from our route definitions and type schemas, which means documentation stays in sync with the implementation automatically. But auto-generated docs aren't enough — they need curation. Every endpoint gets three things that can't be auto-generated: a plain-language description of what it does and when to use it, at least two example requests with realistic data, and a list of common error scenarios with resolution steps.

The investment in documentation pays back immediately. When we shipped a client API with comprehensive docs, their integration time dropped from an average of 3 weeks to 4 days. The docs answered the questions their developers would have asked our support team.

Designing APIs that scale isn't about picking the right framework or following the trendiest paradigm. It's about making deliberate trade-offs based on your actual constraints, documenting those trade-offs, and building patterns that your team can apply consistently across every endpoint. The patterns in this article have survived millions of requests across multiple production systems. They'll survive yours too.

If you're designing an API and want a second opinion on your architecture, reach out. We've seen enough API designs — good and bad — to spot problems before they become expensive.

Leave a comment

Explore
Drag