Helpful context:


Open any mobile app and watch the network traffic. Instagram fetches your feed. Uber calculates a route. Spotify loads your playlist. Strip away the UI - the animations, the buttons, the fonts - and what remains is a thin client making HTTP calls to a server somewhere, receiving JSON, and rendering it. The entire application logic lives on the other end of those API calls. This post is about what’s on that other end and how it’s designed.

How REST Was Born (and Why SOAP Deserved to Die)

Before REST, web services spoke SOAP - Simple Object Access Protocol. SOAP sent XML wrapped in XML wrapped in more XML, required a WSDL schema document to define the interface, and mandated a formal contract between client and server that made evolution painful. Integrating two SOAP services felt like negotiating a treaty.

In 2000, Roy Fielding published his PhD dissertation at UC Irvine. He’d spent years working on the HTTP specification and noticed that the web itself - the global hypertext system - had properties that made it work at planetary scale: statelessness, a uniform interface, resource identification through URLs, and the ability to cache responses. He named this architectural style REST: Representational State Transfer.

The insight was elegant. The web was already the largest distributed system ever built, and it worked by having every server expose resources identified by URLs, manipulated by standard verbs (GET, POST, PUT, DELETE), and represented in self-describing formats. If you built APIs the same way, you’d get all the same scalability and interoperability properties for free - without designing a new protocol.

REST “won” the SOAP wars not because it was formally superior but because it was dramatically simpler. You could test a REST API with curl. You didn’t need a special client library. Caching worked with HTTP’s existing caching infrastructure. Any team, in any language, could consume a REST API in an afternoon.

The irony: Fielding has spent the last twenty years complaining that virtually nobody builds true REST APIs. But that’s a discussion for the critique section.

HTTP Methods: The Semantics Matter

HTTP verbs aren’t just labels. They carry formal semantics about safety and idempotency, and these semantics have practical consequences for retries, caching, and failure recovery.

Method Meaning Safe? Idempotent?
GET Retrieve a resource Yes Yes
POST Create a new resource or trigger an action No No
PUT Replace a resource entirely No Yes
PATCH Partially update a resource No No
DELETE Remove a resource No Yes

Safe means the operation has no side effects - calling it doesn’t change server state. Browsers and proxies exploit this: they’ll retry a GET automatically after a network error, but they’ll warn you before retrying a POST.

Idempotent means calling the operation N times produces the same result as calling it once. PUT to replace a resource is idempotent: replace-with-X ten times is the same as replace-with-X once. DELETE is idempotent: deleting something that’s already deleted is still “it doesn’t exist.” POST is neither safe nor idempotent: submit the same payment form twice and you might get charged twice.

The Idempotency Key Pattern

If you need POST semantics (creating a resource) with idempotency (safe to retry), use an idempotency key. The client generates a unique UUID for the operation and sends it in a header:

POST /payments
Idempotency-Key: 7f3a9c12-4e8b-4d9f-a8c1-2b3e4f5a6b7c

{"amount": 100, "currency": "USD", "card_token": "tok_xyz"}

The server records the idempotency key and its result. If the same key arrives again (because the client retried after a network timeout), the server returns the stored result without processing the payment again. Stripe uses this pattern exactly. It’s what makes payment APIs safe to retry.

Resource Design: Nouns, Not Verbs

REST resources are nouns. URLs identify things, not actions. The HTTP verb expresses the action. Confusing the two produces APIs that are technically functional but semantically incoherent.

# Good: resource-oriented
GET    /users/42/orders          # list orders for user 42
POST   /users/42/orders          # create an order for user 42
GET    /orders/9981              # get a specific order
PATCH  /orders/9981              # update order 9981 (status, notes)
DELETE /orders/9981              # cancel order 9981

# Bad: action-oriented (REST in name only)
POST /getOrdersForUser
POST /cancelOrder?id=9981
POST /updateOrderStatus

The second style is RPC - you’re calling named procedures. It works but forfeits HTTP’s caching, discoverability, and semantic clarity. More importantly, it signals that the designer was thinking about what the server does rather than what the server has.

Nested resources (/users/42/orders) should be used when the child resource doesn’t have a meaningful existence outside the parent, or when the parent ID is part of the access control check. Flat resources (/orders/9981) work when the resource has global identity and you need to access it without knowing the parent.

Status Codes: The Contract

Status codes are how the server communicates the outcome of an operation. Using them correctly means clients can handle errors generically without parsing the response body.

The 5 families:

  • 2xx - success. 200 OK (with body), 201 Created (include Location header), 204 No Content (successful but nothing to return).
  • 3xx - redirect. 301 Permanent, 302 Temporary, 304 Not Modified (cache hit).
  • 4xx - client error (you did something wrong). 400 Bad Request, 401 Unauthenticated, 403 Forbidden, 404 Not Found, 409 Conflict, 422 Unprocessable Entity (structurally valid JSON, but fails validation), 429 Too Many Requests.
  • 5xx - server error (we did something wrong). 500 Internal Server Error, 502 Bad Gateway (upstream failed), 503 Service Unavailable (overloaded or deploying).

The 401/403 distinction is commonly blurred. 401 means “I don’t know who you are - provide credentials.” 403 means “I know exactly who you are, and you’re not allowed to do this.” Returning 404 for unauthorized resources is a deliberate security choice: it reveals less about what exists, though it sacrifices diagnostic clarity.

Authentication: JWT, OAuth2, and Their Tradeoffs

API keys are the simplest mechanism. A long random token, sent as Authorization: Bearer <token> or sometimes in a custom header. The server looks it up in a database. Simple, but synchronous database lookup on every request.

JWT (JSON Web Token) avoids the database lookup. The token is a self-contained signed document:

eyJhbGciOiJSUzI1NiJ9          ← header (base64: {"alg":"RS256"})
.eyJzdWIiOiI0MiIsImV4cCI6MTcyNTM5MzYwMCwicm9sZSI6ImFkbWluIn0
.{signature}

The server verifies the signature using its public key and reads the claims from the payload - no database lookup. This makes JWT excellent for stateless horizontal scaling. The tradeoff: you can’t revoke a JWT before its expiry. If a user’s token is stolen, you can’t invalidate it short of changing the signing key (which revokes everyone). Best practice: short expiry (15 minutes) combined with a refresh token stored server-side that can be revoked.

OAuth 2.0 is a delegation framework, not an authentication protocol. When your app needs to access a user’s Google Calendar, OAuth lets the user grant that access without giving you their password. The Authorization Code flow:

  1. Redirect user to Google’s authorization page.
  2. User approves. Google redirects back with a short-lived code.
  3. Your server exchanges the code for an access token (and refresh token).
  4. Use the access token to call Google APIs on the user’s behalf.

OpenID Connect (OIDC) is OAuth 2.0 plus an identity layer - you get an id_token (a JWT) with user claims. This is what “Sign in with Google” implements.

Pagination: Offset vs Cursor

Offset pagination (GET /posts?offset=100&limit=50) is simple to implement and understand. The database translates it directly to LIMIT 50 OFFSET 100. The problem: if someone inserts a row at position 85 while you’re paginating, your second page shifts and you either see a row twice or miss one entirely. At large offsets, the database must scan and discard all preceding rows - slow.

Cursor pagination (GET /posts?after=eyJpZCI6MTAwfQ&limit=50) encodes a position in the result set, typically a base64-encoded ID or timestamp. The query becomes WHERE id > 100 ORDER BY id LIMIT 50. Consistent under concurrent writes, efficient (uses an index), but can’t jump to an arbitrary page. This is the right choice for feeds, activity streams, and any API consumed by a mobile app that pagates forward continuously.

REST vs GraphQL vs gRPC

The rise of GraphQL and gRPC reflects genuine problems with REST, not fashion.

GraphQL (Facebook, 2015) lets clients specify exactly which fields they want. The canonical REST problem it solves: you have a /users/42 endpoint that returns 40 fields, but the mobile app only needs 3 of them (over-fetching). Or you have a /feed endpoint that returns 20 posts, but each post requires a separate /users/{id} call to get the author’s name (N+1 under-fetching). GraphQL solves both with a single query that specifies exactly the shape of the response.

The GraphQL tradeoffs that are frequently glossed over:

  • Caching is hard. REST leverages HTTP caching naturally: a GET to /users/42 has a URL that can be cached by a CDN, a browser, or a proxy. A GraphQL query is a POST with a JSON body - CDNs don’t cache POST requests. You need application-level caching (persisted queries, Dataloader, response caching by operation hash).
  • The N+1 problem exists server-side too. A GraphQL query for 20 posts with their authors requires the resolver to make 20 separate database calls for authors unless you batch them with Dataloader.
  • Authorization is more complex: field-level permissions require resolvers to check authorization for every field, not just every endpoint.

gRPC (Google, 2016) is contract-first binary RPC over HTTP/2. You define your API in a .proto file, compile it to generate strongly-typed client and server code in any language, and communicate using Protocol Buffers (a compact binary serialization that’s 3 - 10x smaller than equivalent JSON).

service UserService {
  rpc GetUser (GetUserRequest) returns (User);
  rpc ListUsers (ListUsersRequest) returns (stream User);
}

gRPC is ideal for microservice-to-microservice communication: tight contracts, efficient serialization, streaming support built in, and generated client libraries eliminate hand-written HTTP plumbing. It’s a poor fit for browser clients (gRPC requires HTTP/2 trailers which browsers don’t support directly - gRPC-Web is a workaround) and for public APIs where human readability matters.

Dimension REST GraphQL gRPC
Format JSON over HTTP JSON over HTTP Protobuf over HTTP/2
Contract Informal (OpenAPI helps) Strongly typed schema Strongly typed .proto
Caching HTTP caching works naturally Requires custom layer No HTTP caching
Browser support Native Native Needs gRPC-Web
Best for Public APIs, CRUD Flexible client needs Internal microservices
Learning curve Low Medium Medium-High

CORS: Why Your API Works in Postman but Fails in the Browser

This is one of the most common API bugs developers encounter, and it confuses almost everyone the first time. The error message looks like a server failure, but the server is not involved. The browser is blocking the request before it even leaves the machine.

The Same-Origin Policy

Browsers enforce a security rule called the same-origin policy: a web page loaded from one origin can only make requests to the same origin. An “origin” is the combination of protocol, hostname, and port. These are all different origins:

  • http://localhost:3000 (your frontend dev server)
  • http://localhost:8000 (your backend dev server)
  • https://api.yourapp.com (your production API)
  • https://yourapp.com (your production frontend)

A JavaScript file served from http://localhost:3000 cannot make a fetch request to http://localhost:8000. Even though both are on your own machine - the port is different, so the origin is different, and the browser blocks the request.

Postman does not have this restriction because Postman is not a browser. It is a standalone application with no notion of “the page’s origin.” It just makes HTTP requests. When your API works in Postman but fails in the browser, the server is behaving correctly - the browser is doing its job.

What CORS Is

CORS (Cross-Origin Resource Sharing) is the mechanism browsers use to allow servers to explicitly opt into cross-origin requests. The server signals, through HTTP response headers, which origins are allowed to read its responses.

The key header is Access-Control-Allow-Origin:

Access-Control-Allow-Origin: https://yourapp.com

This tells the browser: “it is okay for JavaScript served from https://yourapp.com to read this response.” Without this header, the browser makes the request, receives the response, and then discards it before letting JavaScript see it. The response is not blocked - it is suppressed after arrival.

You can also use a wildcard:

Access-Control-Allow-Origin: *

This allows any origin to read the response. Fine for public APIs. Not acceptable for APIs that use cookies or authorization headers, because Access-Control-Allow-Origin: * cannot be combined with Access-Control-Allow-Credentials: true. The browser refuses this combination - the wildcard plus credentials is a security hole.

For authenticated APIs, you must specify the exact allowed origin:

Access-Control-Allow-Origin: https://yourapp.com
Access-Control-Allow-Credentials: true

The Preflight Request

For requests that might have side effects (non-GET methods, requests with custom headers, requests with JSON bodies), the browser sends a preliminary preflight OPTIONS request before the real request. The preflight asks: “Is this request allowed?”

OPTIONS /api/users/42
Origin: https://yourapp.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization

If the server responds correctly, the browser proceeds with the real request:

Access-Control-Allow-Origin: https://yourapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

Access-Control-Max-Age tells the browser how many seconds to cache the preflight result. Without it, the browser sends a preflight before every non-simple request. With a long max-age (86400 = 24 hours), the browser only sends the preflight once per day per endpoint.

The Most Important Misconception

CORS is not a server-side security feature. It is a browser-side enforcement mechanism that the server signals. A malicious script running outside a browser bypasses CORS entirely - it just makes HTTP requests directly, like Postman does. CORS only protects against malicious JavaScript running inside a browser, where the browser enforces the policy.

This means: configuring CORS does not protect your API from unauthorized access. It only controls which frontend origins can make requests from within a browser context. You still need authentication, authorization, rate limiting, and all your other security controls.

Fixing CORS

Add the CORS headers in your server or reverse proxy. In Python (FastAPI):

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://yourapp.com"],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Authorization", "Content-Type"],
)

In nginx (if CORS is handled at the proxy layer):

add_header Access-Control-Allow-Origin "https://yourapp.com";
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type";

if ($request_method = OPTIONS) {
    return 204;
}

Configuring CORS at the reverse proxy level is often cleaner than adding it to every service individually.


API Versioning: Changing an API Without Breaking Your Clients

Once external clients depend on your API, you cannot freely change its shape. Remove a field, rename a parameter, change a response format - and any client that relied on the old contract breaks silently. Version management is how you evolve APIs without surprise breakage.

When You Need a New Version

Not every change requires a version bump. Non-breaking changes - adding new optional fields to responses, adding new optional query parameters, adding new endpoints - can be shipped without a version change. Old clients ignore fields they do not know about.

Breaking changes require a new version: removing a response field that clients read, changing a field’s data type, changing the meaning of a parameter, changing authentication requirements.

The discipline is to always add, never remove or change existing fields. When you need to change something, add a new field with the new behavior and deprecate the old one.

Three Versioning Approaches

URL versioning - the most common approach:

GET /v1/users/42
GET /v2/users/42

The version is explicit in the URL. Easy to test (just change the URL), easy to route at the load balancer or API gateway (match the path prefix), and cache-friendly (CDNs and proxies can cache versioned URLs independently). The downside: some argue it violates REST (the same resource should have one canonical URL). In practice, it is the most maintainable approach for public APIs.

Header versioning - the “proper REST” approach:

GET /users/42
Accept: application/vnd.myapi.v2+json

The URL stays stable; the requested version travels in the Accept header. This is technically more correct - the same resource is being requested, just in a different representation. The downside: impossible to test by typing a URL into a browser, not cache-friendly (CDNs generally cannot distinguish requests with different custom headers), and easy to forget to set in clients.

Query parameter versioning - a middle ground:

GET /users/42?version=2

Simple to add and test. The version is visible. The downside: it pollutes the URL namespace, and URL versioning is generally preferred for its clarity.

Deprecation and Sunset

When you release v2, v1 does not die immediately. Existing clients still depend on it. The right approach:

  1. Announce the sunset date in documentation and in response headers:
Sunset: Sat, 31 Dec 2025 23:59:59 GMT
Deprecation: true
Link: <https://api.yourapp.com/v2/users>; rel="successor-version"
  1. Monitor traffic to v1 endpoints. As clients migrate, v1 traffic decreases. Do not shut down v1 until traffic drops to near zero.

  2. After the sunset date, return 410 Gone instead of a valid response. This is a cleaner failure than 404 - it tells clients the endpoint existed but has been intentionally retired, not that they have the wrong URL.

The most common mistake: promising backwards compatibility, then breaking it anyway because maintaining multiple versions is expensive. The real solution is designing additive APIs that rarely need breaking changes - prefer adding new fields and new endpoints over modifying existing ones.


The Critique: “RESTful” as a Misnomer

Fielding defined REST with five constraints. The most ignored one is HATEOAS - Hypermedia As The Engine Of Application State. In a true REST API, the response includes links to related actions:

{
  "order": {"id": 9981, "status": "shipped"},
  "_links": {
    "cancel": {"href": "/orders/9981/cancel", "method": "DELETE"},
    "track": {"href": "/shipments/XYZ123"}
  }
}

The client discovers actions from the response rather than hard-coding URLs. This is how the web works: you follow links. It’s what makes HTML pages navigable without clients needing to know the server’s URL structure in advance.

Virtually every API called “REST” ignores HATEOAS. The client hard-codes /users/{id}/orders because it was documented, not because it was discovered at runtime. This isn’t necessarily wrong - pragmatic REST without HATEOAS works fine - but it means most “REST” APIs are really just HTTP APIs with JSON and some resource naming conventions.

Cloud and API Gateway Patterns

In production, API requests rarely hit your application server directly. They go through an API gateway first.

AWS API Gateway sits in front of your Lambda functions or backend services, handling authentication (IAM, Cognito, Lambda authorizers), rate limiting, request transformation, and routing. It can terminate your REST or WebSocket API entirely, routing requests to different backends based on path or method.

Kong and AWS API Gateway both implement the gateway pattern but with different tradeoffs. Kong is open-source and self-hosted, giving you control but requiring operational effort. It supports plugins for auth, rate limiting, logging, and observability that snap onto any upstream service.

In a microservices architecture, the API gateway is the single entry point for external traffic. Services talk to each other internally over gRPC or HTTP with service mesh (Envoy/Istio), completely decoupled from the external REST API surface. This means you can change your internal service topology without breaking public API clients.

Future Outlook

The REST vs GraphQL vs gRPC debate has matured. Most large platforms settle on a pattern: REST or GraphQL for external/public APIs (developer experience matters), gRPC for internal service-to-service calls (performance matters), and an API gateway layer in between to translate.

tRPC is gaining traction in TypeScript full-stack applications - it skips the serialization layer entirely and gives end-to-end type safety between Next.js server and client without a schema definition step. Whether it scales beyond tightly-coupled full-stack apps is an open question.

The deeper shift is toward edge computing. Cloudflare Workers and Vercel Edge Functions run API logic in data centers close to users, reducing latency dramatically. This works for stateless API logic but pushes database latency concerns to the foreground - your edge function runs in Amsterdam, your Postgres is in us-east-1. Edge-compatible databases (Turso, PlanetScale, Cloudflare D1) are the necessary infrastructure complement.


Concept Key Point
REST origin Fielding 2000; modeled on the web’s own design principles
Idempotency GET, PUT, DELETE safe to retry; POST is not
Idempotency keys Client-generated UUIDs make POST retries safe (Stripe pattern)
Status codes 401 = unauthenticated, 403 = unauthorized, 422 = validation error
JWT Self-contained, no DB lookup, but can’t revoke before expiry
OAuth 2.0 Delegation framework; OIDC adds identity on top
Cursor pagination Consistent under concurrent writes; better than offset for feeds
GraphQL Solves over/under-fetching; breaks HTTP caching
gRPC Binary, typed, efficient; ideal for internal services
HATEOAS Almost nobody does it; most “REST” APIs are really HTTP+JSON
API gateway Auth, rate limiting, routing in one place; AWS API Gateway, Kong
CORS Browser enforces same-origin policy; server opts in via Access-Control-Allow-Origin
Preflight Browser sends OPTIONS before non-simple requests to check if CORS is allowed
API versioning URL versioning is most common; non-breaking changes (adding fields) don’t need a version bump

Read Next: