🏗️ REST API Best Practices

We all know how to build REST APIs? Right? 👀 Here we go again… this time I wanted to take a look at REST, and share the most useful best practices I’ve gathered over the years.

📃 Content:

🧠 What is REST?

In 1991, the Hypertext Transfer Protocol (HTTP/0.9) was introduced. It is the foundation of data communication for the World Wide Web, where hypertext documents include hyperlinks to other resources that users can easily access. Here is some chronology:

Year HTTP version Methods Included
1991 HTTP/0.9 GET Simple protocol designed to fetch raw HTML documents over the web
1996 HTTP/1.0 POST, HEAD Headers, response status codes, enabling form submissions and metadata retrieval
1997 HTTP/1.1 PUT, DELETE, OPTIONS, TRACE, CONNECT Persistent connections, chunked transfers, and robust resource manipulation capabilities
2010 - PATCH -
2015 HTTP/2.0 - Binary framing, request multiplexing, header compression (HPACK), and server push
2022 HTTP/3.0 - Replaced TCP with QUIC (running over UDP) to eliminate head-of-line blocking and drastically speed up handshakes on unstable mobile networks
2026 - QUERY -

In 2000, a new pattern for using HTTP was described: Representational State Transfer (REST) was defined by Roy Fielding in his doctoral dissertation Architectural Styles and the Design of Network-based Software Architectures to provide a formal architectural blueprint for the World Wide Web. REST refers to a group of software architecture design constraints that bring about efficient, reliable, and scalable distributed systems:

  1. Client/Server: Separates user interface concerns from data storage and business logic concerns. This separation of concerns improves the portability of user interfaces across multiple platforms and allows server components to scale independently.
  2. Stateless: Requires that every request from a client contains all the necessary context and information required to process it. The server cannot rely on any previously stored session context and any session state is maintained entirely on the client side.
  3. Cacheable: Responses must be explicitly or implicitly labeled as cacheable or non-cacheable. If a response is cacheable, the client cache is authorized to reuse that response data for identical subsequent requests, reducing network bandwidth and latency.
  4. Layered system: Restricts component behavior such that each component cannot see beyond the immediate layer with which they are interacting. A client may communicate with an intermediary (such as a load balancer, reverse proxy, or security gateway) without knowing whether it is talking to the ultimate server, enabling better scalability and security architecture.
  5. Code on demand (optional): Servers are able to temporarily extend or customize the functionality of a client by transferring logic to the client that can be executed within a standard virtual machine.
  6. Uniform interface: The defining characteristic of REST architecture that simplifies and decouples the architecture. It relies on four guiding constraints:
    1. Resource identification: Individual resources are uniquely identified using Uniform Resource Identifiers (URIs).
    2. Resource manipulation through representations: Clients interact with resources through representations (HTML, XML or plain text) rather than directly accessing the underlying database.

      👀 Notice that the JSON representation did not appear until 2001.

    3. Self-descriptive messages: Each message includes enough information to describe how to process it (standard HTTP methods like GET, POST, PUT, DELETE, and media type headers).
    4. Hypermedia as the engine of application state (HATEOAS): Clients navigate the application dynamically by following hyperlinks provided within the server’s responses, rather than hardcoding URIs.

The basic idea of REST is that a resource, for example a document, is transferred via well-recognized, language-agnostic, and reliably standardized client/server interactions. Services are called RESTful when they follow these constraints.

🤔 Do we always have to follow these constraints?

It depends. Nowadays, people call any API a REST API. In a later publication, REST APIs must be hypertext-driven (2008), Roy Fielding declared: “I am getting frustrated by the number of people calling any HTTP-based interface a REST API. Today’s example is the SocialSite REST API. That is RPC. It screams RPC.”

The reality is that many modern APIs sacrifice one or more of these constraints for the sake of simplicity, performance, or development speed.

For me, REST was not created to fit the modern web’s reality, but it is still a powerful design tool, and we should keep questioning some of these constraints because doing so helps us write better distributed and scalable software:

Constraint Do you have to follow it? Why?
Client-Server Architecture Artifacts must remain decoupled, allow front-end and back-end to evolve independently
Statelessness It depends. Each request from client to server must contain all the information necessary to understand and process the request. But sometimes working with HTTP cookies or external session stores compromises the constraint
Cacheability Depends on data, it’s optional but highly recommended for performance
Layered System Layered design ensures intermediaries (CDNs, API gateways, load balancers, proxies, etc.) don’t break functionality, increasing scalability and security
Code-on-Demand (optional) Obsolete, modern APIs do not use code-on-demand. They use their own precompiled logic
Identification of resources URIs identify individual resources (for example: /users/123)
Manipulation of resources through representations Clients receive representations of resources (usually JSON)
Self-descriptive messages Each message includes enough information to describe how to process it, for example correct Content-Type headers and use correct HTTP method to alter them
HATEOAS Obsolete, responses rarely contain links to other related actions and resources

👩🏻‍💻 Good Practices

1. Resource Naming

This is not part of the original REST architectural style, Roy Fielding deliberately left URI naming conventions opaque to the architecture. Naming conventions were popularized in later books like REST API Design Rulebook (2011) by Mark Massé:

  • Use nouns instead of verbs for example /users instead of /getUsers.
  • Use lowercase letters.
  • Use hyphens for readability for example /user-management/users instead of underscores.
  • Avoid file extensions and / at the end.
  • Avoid going deeper than collection/resource/collection.
  • Don’t mirror database structure in URLs to prevent exposing unnecessary information.
  • We have 4 archetypes:
  1. Document: that is a singular concept.
/leagues/seattle
/leagues/seattle/teams/trebuchet
/leagues/seattle/teams/trebuchet/players/{id}
  1. Collection: this is a directory of resources managed by the server, nouns in plural.
/leagues
/leagues/seattle/teams
/leagues/seattle/teams/trebuchet/players
  1. Store: this is a directory of resources managed by the client (URI decided by the client).
PUT /users/1234/favorites/alonso
  1. Controller: Procedural concept, executable functions with parameters, return values, inputs and outputs, names use verbs in the last segment.
POST /alerts/245743/resend

2. The Right HTTP Method

  • Use the correct method for the semantic action.
  • Keep safe methods side-effect-free, without impacting the server state.
  • Keep methods idempotent, making multiple identical requests produce the same result as a single request.

    👀 For sensitive operations, consider using idempotency keys (Notice that POST is not idempotent by default).

  • In case of complex searches that cannot be resolved with query params, use a request body in a QUERY method instead of GET.
Method Safe Idempotent
GET
POST
PUT
PATCH
DELETE
QUERY
  • Sometimes POST method serves many valid purposes in HTTP, including acting as a general-purpose method for actions that don’t fit neatly into other standard methods (or actions “not worth standardizing”). Roy Fielding talked about this in It is okay to use POST (2009).

3. Meaningful Status Codes

Do not always return 200 OK by default. Leverage standard HTTP status categories:

  • Use 200 OK as a general response for all requests returning data.
  • Use 201 Created for POST method.
  • Use 202 Accepted, for asynchronous processing. The request was accepted, but execution has not finished yet.
  • Use 204 No Content, action completed successfully, but there is no body in the response: PUT, PATCH, DELETE. (👀 This depends if you decide to return the new state in the payload or not).
  • Use 400 Bad Request, payload or query parameters are malformed or invalid (for example: failed JSON validation).
  • Use 401 Unauthorized when Authentication is required.
  • Use 403 Forbidden when authenticated, but does not have permission to perform an action.
  • Use 404 Not Found when request URI does not exist.
  • Use 409 Conflict when request conflicts with current state (for example: registering a user with an already existing email address).
  • Use 422 Unprocessable Entity when request syntax is valid, but contains semantic errors (for example: field validation errors).

4. Versioning

API versioning is essential for maintaining stability, preventing service disruptions, and allowing systems to evolve without breaking existing integrations. But, it’s not FREE ⚠️. Notice that it creates maintenance overhead of multiple versions, which increases complexity. Also notice that it automatically creates technical debt, not only on the server side, but also in every client. Such as the “have to migrate to v2” task in all clients, and this could take years or will coexist forever.

Roy Fielding is more “radical”: “v1 is a middle finger to your API customers” 🖕. For him well designed webs have no versions, and if they follow strict REST, they have enough extensibility to operate and add new functionality instead of forcing clients to rewrite their code with a V2 version.

For me it’s a trade-off, and it’s the perfect example of “Just because you can, doesn’t mean you should”. If v1 has no deadline, the maintenance cost should be in the table, multiplied by clients, and projected to the next 5 years. Only then, you can check if it’s worth the price to create a V2.

We have multiple options for versioning with pros and cons:

  • Path versioning: /v1/users, /v2/users (For example: Youtube or Dropbox)
  • Hostname versioning: api-v1.example.com, api-v2.example.com
  • Body or Query Params versioning: ?version=1.0, ?version=2.0

5. Pagination

Pagination prevents performance bottlenecks and memory overload on server and client. It’s especially recommended for large collections. We mainly got two different approaches depending on the problem we are solving:

Offset Pagination:

GET /api/v1/products?limit=2&offset=2 HTTP/1.1
Host: api.example.com
{
  "data": [
    {
      "id": 3,
      "name": "Wireless Mechanical Keyboard",
      "price": 89.99
    },
    {
      "id": 4,
      "name": "Ergonomic Vertical Mouse",
      "price": 49.99
    }
  ],
  "meta": {
    "total_items": 45,
    "limit": 2,
    "offset": 2,
    "total_pages": 23
  }
}
  • Easy to implement, you can just map directly to standard SQL LIMIT and OFFSET clause.
  • Good for experiences where you want pages, for example search in a booking system.
  • Poor at scale: Database must scan and discard all skipped rows, slowing down as offsets grows.
  • Vulnerable to data drift: Possible duplicate or missed entries if data is inserted or deleted while browsing.

Cursor Pagination:

GET /api/v1/products?limit=2&after=eyJpZCI6Mn0= HTTP/1.1
Host: api.example.com
{
  "data": [
    {
      "id": 3,
      "name": "Wireless Mechanical Keyboard",
      "price": 89.99
    },
    {
      "id": 4,
      "name": "Ergonomic Vertical Mouse",
      "price": 49.99
    }
  ],
  "pagination": {
    "next_cursor": "eyJpZCI6NH0=",
    "prev_cursor": "eyJpZCI6M30=",
    "has_more": true
  }
}
  • More complex to implement, Requires encoding unique, sorted identifiers (like IDs or timestamps) into tokens.
  • High performance: Uses indexed lookups directly, keeping query speed constant regardless of dataset size.
  • Random access: Not supported, only allows sequential navigation (Ideal for infinite scroll).
  • Resilient to data drift: Stays accurate even when underlying data changes in real time.

6. Filtering and Sorting

In general, these techniques can improve server & client performance and extensibility, but sometimes add more complexity to queries (especially when using dynamic WHERE and ORDER BY), which will have a direct impact on our database design and index strategy.

Also, they increase the attack surface for query injections or denial-of-service attacks. We have to perform extra validations for inputs. On the other hand, each unique combination of query parameters creates a distinct cache key, reducing overall cache hit ratios.

Filtering

Filtering allows clients to request a specific subset of resources based on attributes, keeping payloads lean and relevant.

We can define filters in query params or body, and we can also provide a catalog of operators (ne, gt, gte, lt, lte, in, min, max, like, contains, etc.), combined filters, etc. One simple example could be:

GET /api/v1/products?category=electronics&price_lte=100 HTTP/1.1
Host: api.example.com
{
  "data": [
    {
      "id": 12,
      "name": "Wireless Earbuds",
      "category": "electronics",
      "price": 59.99
    },
    {
      "id": 18,
      "name": "USB-C Hub",
      "category": "electronics",
      "price": 34.99
    }
  ],
  "meta": {
    "total_items": 2,
    "filters_applied": {
      "category": "electronics",
      "price_lte": 100
    }
  }
}

Sorting

Sorting lets clients control the order of results using explicit parameters, supporting ascending/descending directions and multi-field sorting. For example:

GET /api/v1/products?sort=-price,name:asc HTTP/1.1
Host: api.example.com
{
  "data": [
    {
      "id": 5,
      "name": "Ultrawide Monitor",
      "category": "electronics",
      "price": 499.99
    },
    {
      "id": 4,
      "name": "Ergonomic Mouse",
      "category": "electronics",
      "price": 89.99
    },
    {
      "id": 3,
      "name": "Mechanical Keyboard",
      "category": "electronics",
      "price": 89.99
    }
  ],
  "meta": {
    "total_items": 3,
    "sort_applied": "-price,name:asc"
  }
}

7. Standard Error Formats

Instead of inventing a custom format, adopting an established open standard helps leverage existing library support and API tooling. For example Problem Details for HTTP APIs (RFC 9457):

Example of an application/problem+json media type response:

{
  "type": "https://example.com/errors/insufficient-funds",
  "title": "Insufficient Funds",
  "status": 403,
  "detail": "Your current balance is $30.00, but the transaction costs $50.00.",
  "instance": "/accounts/12345/transactions/abc987",
  "balance": 30.00,
  "accounts": ["/accounts/12345"]
}

For instance, Microsoft has adopted this standard as Problem Details (RFC 7807/RFC 9457) specified in the ProblemDetails Class:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "traceId": "00-91a382c4-00",
  "errors": {
    "Email": ["The Email field is required."]
  }
}

8. Rate limiting

Rate limiting restricts how many requests a client can make to your API within a given timeframe. It contributes to security and stability for modern Web APIs.

  • Pick the right limit algorithm depending on complexity, memory consumption, burst exposure, and precision:
    • Fixed Window Counters: Counts requests in fixed time blocks (for example 00:00 to 01:00), easy to implement, low memory. Problem with window boundaries (with a limit of 100 per minute, someone can send 100 at 0:59 and 100 at 1:01, so 200 in 2 seconds), is less precise control for highly variable traffic. Low precision.
    • Sliding Window Log: Tracks timestamps of every request made by a client in a rolling time window. Statistically 100% precision. High memory consumption when scaled to millions of requests.
    • Sliding Window Counter: Combines two approaches. Combines fixed windows with a weighted calculation based on the previous window’s request volume and dispersion. More complex to implement. Burst control. Very low memory consumption. Statistically 99.5% precision.
    • Token Bucket: A bucket holds a fixed capacity of tokens. Tokens are added at a constant rate. Each request consumes a token. Bursts are allowed and controlled. Very low memory consumption. Requires tuning the size and refill rate. High precision.
    • Leaky Bucket: Requests enter a queue (the bucket) and are processed at a steady, fixed output rate regardless of incoming burst speed. Guarantees a continuous, steady flow of requests. High latency if the bucket fills up. Eliminates bursts by design. Low memory consumption (just the FIFO queue). Very high precision.
  • Return the correct Status Code 429 Too Many Requests.
  • Add Retry-After header to communicate to the client when they will be able to try again.
  • Add standard rate limit headers:
    • X-RateLimit-Limit: Maximum requests allowed per period (for example: 1000).
    • X-RateLimit-Remaining: Number of requests remaining in the current period.
    • X-RateLimit-Reset: UTC epoch timestamp indicating when the current window resets (for example: 1789225020).
  • Avoid IP-Only rate limiting for authenticated routes: Limiting solely by IP address can mistakenly throttle hundreds of legitimate users sharing a corporate NAT, VPN, or mobile carrier connection.
  • Limit by unique credentials: For authenticated requests, track usage by API Key, User ID, or Account/Tenant ID.
  • Fallback to IP/Fingerprinting for public routes: Use client IP addresses or request fingerprints (For example: combinations of IP, user agent, and origin headers) only on unauthenticated endpoints like /login or /register.
  • Decouple rate limiting: Enforce network-level rate limits at layer 3/4 (IP and TCP connection limits via firewalls or cloud infrastructure) and application-level limits at layer 7. For layer 7 protection, push rate limiting outward to your API Gateway, Load Balancer, or Reverse Proxy (for example: NGINX, Kong, Cloudflare). When running rate limiting within your application, use middleware (for example: ASP.NET Core Rate Limiting Middleware) paired with a distributed in-memory store (like Redis) for horizontally scaled environments.

9. Partial responses

Support partial content retrieval for large resources (for example: video files). The basic idea is to split content in chunks in different requests:

  1. Client can make a very fast HEAD request to check resource size:
HEAD /media/example-video.mp4
Response headers:
Accept-Ranges: bytes
Content-Length: 104857600 # 100MB
Content-Type: video/mp4
  1. Client can request specific chunks using the Range header:
GET /media/example-video.mp4
Range: bytes=0-1048575  # Request first 1MB
  1. Server responds with partial content:
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1048575/100000000
Content-Length: 1048576
[... first 1MB of data ...]
  1. Client can request next chunk:
GET /files/big-video.mp4
Range: bytes=1048576-2097151  # Request second 1MB

10. Documentation

The most extended way to document APIs nowadays is using OpenAPI Specification (OAS). It is considered an industry standard because it transforms static documentation into a single machine-readable contract (YAML or JSON) that powers your entire development lifecycle. It enables interactive documentation (like Swagger UI or Scalar), automated client and server code generation in multiple languages (like NSWAG), contract validation and documentation, testing and validation, MCP integration, etc.

openapi: 3.0.3
info:
  title: Example API
  version: 1.0.0
paths:
  /health:
    get:
      summary: Health Check API status
      responses:
        '200':
          description: Service is healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: "ok"

📃 Some References