← All articles
ARCHITECTURE API Design Best Practices: REST vs GraphQL vs gRPC 2026-02-09 · 9 min read · api · rest · graphql

API Design Best Practices: REST vs GraphQL vs gRPC

Architecture 2026-02-09 · 9 min read api rest graphql grpc architecture api-design

API Design Best Practices: REST vs GraphQL vs gRPC

Choosing an API paradigm is one of the most consequential early decisions in a project. Get it right, and your API becomes a pleasure to use. Get it wrong, and you'll spend years working around the limitations. The good news: the choice is usually obvious once you understand the trade-offs.

REST, GraphQL, and gRPC at a Glance

Aspect REST GraphQL gRPC
Protocol HTTP/1.1 or HTTP/2 HTTP/1.1 or HTTP/2 HTTP/2 (required)
Data format JSON (typically) JSON Protocol Buffers (binary)
Schema Optional (OpenAPI) Required (SDL) Required (.proto)
Overfetching Common Solved by design Solved by design
Browser support Native Native Requires grpc-web proxy
Caching HTTP caching works naturally Complex (POST-based) Not built-in
Learning curve Low Medium High
Best for Public APIs, CRUD apps Complex frontends, mobile Microservices, high performance

The short version: REST is the default for public APIs, GraphQL shines when frontends need flexible data fetching, and gRPC is the right choice for internal service-to-service communication where performance matters.

REST API Design That Doesn't Suck

REST is the most common API style, but most REST APIs are poorly designed. Following a few principles makes the difference between an API developers love and one they dread.

Resource-Oriented URLs

URLs should represent resources (nouns), not actions (verbs). The HTTP method provides the verb.

# Good -- resources as nouns
GET    /users                  # List users
POST   /users                  # Create a user
GET    /users/123              # Get a specific user
PATCH  /users/123              # Update a user
DELETE /users/123              # Delete a user

# Bad -- verbs in URLs
POST   /createUser
GET    /getUserById?id=123
POST   /updateUser
POST   /deleteUser

Nested resources should reflect real relationships, but keep nesting shallow:

# Good -- one level of nesting
GET /users/123/orders          # Orders belonging to user 123
GET /orders/456                # Direct access to order 456

# Bad -- deep nesting
GET /users/123/orders/456/items/789/reviews

Use the Right HTTP Methods and Status Codes

# Express-style pseudocode showing proper method + status code usage

# 200 OK -- successful GET, PATCH, DELETE
GET /users/123        -> 200 { "id": 123, "name": "Alice" }

# 201 Created -- successful POST that creates a resource
POST /users           -> 201 { "id": 124, "name": "Bob" }
                         Location: /users/124

# 204 No Content -- successful DELETE with no response body
DELETE /users/123     -> 204

# 400 Bad Request -- client sent invalid data
POST /users { "email": "not-an-email" } -> 400

# 404 Not Found -- resource doesn't exist
GET /users/99999      -> 404

# 409 Conflict -- resource state conflict
POST /users { "email": "[email protected]" } -> 409  # email taken

# 422 Unprocessable Entity -- valid JSON but failed validation
POST /users { "name": "" } -> 422

Consistent Error Responses

Every API needs a consistent error format. Don't make clients guess the shape of error responses.

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "The request body contains invalid fields.",
    "details": [
      {
        "field": "email",
        "message": "Must be a valid email address.",
        "value": "not-an-email"
      },
      {
        "field": "age",
        "message": "Must be a positive integer.",
        "value": -5
      }
    ]
  }
}

Here's a reusable error handler in Express:

// errors.ts
class ApiError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public details?: Array<{ field: string; message: string; value?: unknown }>
  ) {
    super(message);
  }
}

// Error handler middleware
function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
  if (err instanceof ApiError) {
    return res.status(err.statusCode).json({
      error: {
        code: err.code,
        message: err.message,
        details: err.details,
      },
    });
  }

  // Unexpected errors -- don't leak internals
  console.error("Unhandled error:", err);
  return res.status(500).json({
    error: {
      code: "INTERNAL_ERROR",
      message: "An unexpected error occurred.",
    },
  });
}

// Usage in a route
app.post("/users", async (req, res, next) => {
  try {
    const errors = validateUser(req.body);
    if (errors.length > 0) {
      throw new ApiError(422, "VALIDATION_FAILED", "Invalid input.", errors);
    }
    const user = await createUser(req.body);
    res.status(201).json(user);
  } catch (err) {
    next(err);
  }
});

Pagination Done Right

There are three common pagination approaches. Offset-based is the simplest but worst at scale. Cursor-based is better for most real applications.

Offset-Based Pagination

GET /users?offset=20&limit=10
{
  "data": [...],
  "pagination": {
    "offset": 20,
    "limit": 10,
    "total": 253
  }
}

Problems: Slow on large tables (SQL OFFSET scans rows), inconsistent results when data changes between pages. Fine for admin dashboards with small datasets, bad for everything else.

Cursor-Based Pagination

GET /users?limit=10&after=eyJpZCI6MjB9
{
  "data": [...],
  "pagination": {
    "has_next": true,
    "next_cursor": "eyJpZCI6MzB9",
    "has_previous": true,
    "previous_cursor": "eyJpZCI6MjF9"
  }
}

The cursor is an opaque string (typically a base64-encoded primary key or timestamp). The server uses it to efficiently query the next page:

-- Instead of OFFSET (slow):
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 10000;

-- Cursor-based (fast, uses index):
SELECT * FROM users WHERE id > 10000 ORDER BY id LIMIT 10;

Keyset Pagination Implementation

// Cursor-based pagination with Prisma
async function listUsers(cursor?: string, limit = 20) {
  const decodedCursor = cursor
    ? JSON.parse(Buffer.from(cursor, "base64url").toString())
    : null;

  const users = await prisma.user.findMany({
    take: limit + 1, // Fetch one extra to check for next page
    ...(decodedCursor && {
      cursor: { id: decodedCursor.id },
      skip: 1, // Skip the cursor item itself
    }),
    orderBy: { id: "asc" },
  });

  const hasNext = users.length > limit;
  const data = hasNext ? users.slice(0, -1) : users;

  return {
    data,
    pagination: {
      has_next: hasNext,
      next_cursor: hasNext
        ? Buffer.from(JSON.stringify({ id: data[data.length - 1].id })).toString("base64url")
        : null,
    },
  };
}

API Versioning

There are three approaches to versioning, and the industry has largely settled on URL-based versioning for simplicity:

Strategy Example Pros Cons
URL path /v1/users Obvious, easy to route URL pollution
Header Accept: application/vnd.api.v1+json Clean URLs Hidden, harder to test
Query param /users?version=1 Easy to add Easy to forget

URL-Based Versioning (Recommended)

// Express router setup
import { Router } from "express";
import v1Routes from "./routes/v1";
import v2Routes from "./routes/v2";

const app = express();
app.use("/v1", v1Routes);
app.use("/v2", v2Routes);

// v1 routes -- maintained for backward compatibility
// v2 routes -- current version with breaking changes

When to Version

Don't version eagerly. Only create a new version when you need to make a breaking change. These are breaking:

These are not breaking:

GraphQL: When Flexible Queries Matter

GraphQL solves the overfetching/underfetching problem that plagues REST APIs. Instead of the server deciding what data to return, the client asks for exactly what it needs.

# Schema definition
type User {
  id: ID!
  name: String!
  email: String!
  orders(first: Int, after: String): OrderConnection!
  avatar: String
}

type Order {
  id: ID!
  total: Float!
  status: OrderStatus!
  items: [OrderItem!]!
}

type Query {
  user(id: ID!): User
  users(first: Int, after: String, filter: UserFilter): UserConnection!
}

input UserFilter {
  search: String
  status: UserStatus
}
# Client query -- gets exactly what the mobile app needs
query UserProfile($userId: ID!) {
  user(id: $userId) {
    name
    avatar
    orders(first: 5) {
      edges {
        node {
          total
          status
        }
      }
    }
  }
}

GraphQL's Real Trade-offs

Advantages:

Disadvantages:

The DataLoader Pattern

Without DataLoader, a query for 50 users with their orders creates 51 database queries. DataLoader batches them:

import DataLoader from "dataloader";

// Without DataLoader -- N+1 problem
const resolvers = {
  User: {
    orders: (user) => db.orders.findMany({ where: { userId: user.id } }),
    // Called once per user = 50 queries for 50 users
  },
};

// With DataLoader -- batched
const orderLoader = new DataLoader(async (userIds: string[]) => {
  const orders = await db.orders.findMany({
    where: { userId: { in: userIds } },
  });
  // Group by userId and return in same order as input
  return userIds.map((id) => orders.filter((o) => o.userId === id));
});

const resolvers = {
  User: {
    orders: (user) => orderLoader.load(user.id),
    // All 50 user IDs batched into a single query
  },
};

gRPC: The Microservices Choice

gRPC uses Protocol Buffers for serialization and HTTP/2 for transport. It's significantly faster than JSON-over-HTTP for service-to-service communication.

// user.proto
syntax = "proto3";

package user;

service UserService {
  rpc GetUser (GetUserRequest) returns (User);
  rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
  rpc CreateUser (CreateUserRequest) returns (User);
  rpc StreamUpdates (StreamRequest) returns (stream UserEvent);
}

message User {
  string id = 1;
  string name = 2;
  string email = 3;
  int64 created_at = 4;
}

message GetUserRequest {
  string id = 1;
}

message ListUsersRequest {
  int32 page_size = 1;
  string page_token = 2;
}

message ListUsersResponse {
  repeated User users = 1;
  string next_page_token = 2;
}

When gRPC Makes Sense

When gRPC Doesn't Make Sense

Rate Limiting and Throttling

Every API needs rate limiting. The standard approach uses response headers:

HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1706547200

HTTP/1.1 429 Too Many Requests
Retry-After: 30

Token Bucket Implementation

import { Redis } from "ioredis";

const redis = new Redis();

async function checkRateLimit(
  key: string,
  limit: number,
  windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
  const now = Math.floor(Date.now() / 1000);
  const windowStart = now - windowSeconds;

  const pipeline = redis.pipeline();
  // Remove old entries
  pipeline.zremrangebyscore(key, 0, windowStart);
  // Add current request
  pipeline.zadd(key, now.toString(), `${now}-${Math.random()}`);
  // Count requests in window
  pipeline.zcard(key);
  // Set expiry
  pipeline.expire(key, windowSeconds);

  const results = await pipeline.exec();
  const count = results![2][1] as number;

  return {
    allowed: count <= limit,
    remaining: Math.max(0, limit - count),
    resetAt: now + windowSeconds,
  };
}

Choosing Your API Style: Decision Framework

Choose REST when:

Choose GraphQL when:

Choose gRPC when:

Hybrid approaches work too:

Common API Design Mistakes

  1. Inconsistent naming: Mixing camelCase and snake_case in the same API. Pick one and stick with it.

  2. Leaking internal structure: Your API shouldn't mirror your database schema. Clients don't need to know about your user_profile_settings_v2 table.

  3. Ignoring idempotency: POST requests that create duplicate resources on retry. Use idempotency keys:

app.post("/payments", async (req, res) => {
  const idempotencyKey = req.headers["idempotency-key"];
  if (!idempotencyKey) {
    return res.status(400).json({ error: "Idempotency-Key header required" });
  }

  const existing = await cache.get(`idempotency:${idempotencyKey}`);
  if (existing) {
    return res.status(200).json(JSON.parse(existing));
  }

  const payment = await processPayment(req.body);
  await cache.set(`idempotency:${idempotencyKey}`, JSON.stringify(payment), "EX", 86400);
  res.status(201).json(payment);
});
  1. No request validation: Always validate input at the API boundary, not deep in your business logic.

  2. Returning 200 for everything: Status codes exist for a reason. A 200 response with { "success": false } is an anti-pattern.

Summary

API design isn't about picking the "best" technology. It's about understanding your constraints -- who's consuming the API, what data patterns you have, how important performance is -- and choosing the tool that fits. REST remains the safe default for most projects. GraphQL adds real value when data fetching complexity is your bottleneck. gRPC is the right choice when raw performance between services matters more than developer ergonomics.

Whatever you choose, invest in consistency. A well-designed REST API beats a poorly-designed GraphQL API every time.