Skip to main content

Command Palette

Search for a command to run...

API Design Best Practices: REST vs GraphQL vs gRPC

Published
11 min read
D
Practical guides for developers: TypeScript, developer tools, CI/CD, and modern web development. We cover the tools that make devs more productive.

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.

GraphQL API query language logo

REST, GraphQL, and gRPC at a Glance

AspectRESTGraphQLgRPC
ProtocolHTTP/1.1 or HTTP/2HTTP/1.1 or HTTP/2HTTP/2 (required)
Data formatJSON (typically)JSONProtocol Buffers (binary)
SchemaOptional (OpenAPI)Required (SDL)Required (.proto)
OverfetchingCommonSolved by designSolved by design
Browser supportNativeNativeRequires grpc-web proxy
CachingHTTP caching works naturallyComplex (POST-based)Not built-in
Learning curveLowMediumHigh
Best forPublic APIs, CRUD appsComplex frontends, mobileMicroservices, 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": "alice@example.com" } -> 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:

StrategyExampleProsCons
URL path/v1/usersObvious, easy to routeURL pollution
HeaderAccept: application/vnd.api.v1+jsonClean URLsHidden, harder to test
Query param/users?version=1Easy to addEasy to forget
// 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:

  • Removing a field from a response
  • Changing a field's type (string to integer)
  • Renaming a field
  • Changing authentication requirements
  • Changing error response format

These are not breaking:

  • Adding a new field to a response
  • Adding a new optional query parameter
  • Adding a new endpoint
  • Adding a new enum value (if clients are tolerant)

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:

  • Clients get exactly the data they need (no overfetching)
  • One endpoint for everything (no endpoint sprawl)
  • Strongly typed schema serves as documentation
  • Great for mobile apps where bandwidth matters

Disadvantages:

  • HTTP caching is nearly impossible (everything is POST to one endpoint)
  • N+1 query problem is easy to create, requires DataLoader to solve
  • Rate limiting is harder (can't just limit per-endpoint)
  • File uploads are awkward
  • Error handling is non-standard (always returns 200)

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

  • Internal microservices: The binary format and HTTP/2 multiplexing give measurable performance wins
  • Streaming: gRPC has native support for server streaming, client streaming, and bidirectional streaming
  • Polyglot services: Proto files generate type-safe clients in any language
  • Mobile backends: Smaller payloads and persistent connections reduce battery usage

When gRPC Doesn't Make Sense

  • Public APIs: Browser support requires a proxy (grpc-web), and most third-party developers expect REST or GraphQL
  • Simple CRUD: The tooling overhead isn't worth it for basic operations
  • Small teams: The learning curve for protobuf, code generation, and gRPC concepts is real

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:

  • Building a public API that third-party developers will consume
  • Your data model maps naturally to CRUD operations
  • You want HTTP caching to work out of the box
  • Your team is most comfortable with REST

Choose GraphQL when:

  • Multiple frontends (web, mobile, watch) need different data shapes
  • You're building a data-rich application with complex relationships
  • Frontend teams want to iterate without backend changes
  • You're aggregating data from multiple backend services

Choose gRPC when:

  • Building internal service-to-service communication
  • Performance and bandwidth efficiency matter (high-throughput systems)
  • You need streaming (real-time events, file transfers)
  • Services are written in multiple languages

Hybrid approaches work too:

  • REST for public API + gRPC between internal services
  • GraphQL as a BFF (Backend for Frontend) that calls REST/gRPC services
  • REST for simple resources + WebSockets for real-time features

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.


Enjoyed this guide? Subscribe to DevTools Guide — a free weekly newsletter covering developer tools, workflows, and best practices.

More from this blog

DevTools Guide

183 posts