Home › High-Level Design › REST API Design
R

REST API Design

Almost every system design interview opens at the API. Before anyone draws a database or a cache, the interviewer wants to see how a client talks to your service. Name the right resources, pick the right verbs, return the right status codes without fumbling, and you earn trust in the first five minutes. Fumble it and you spend the rest of the hour climbing out of a hole. I have watched the API section eat the first ten minutes of a 45-minute loop. Get it crisp and you buy time for the hard parts.

High-Level DesignBeginner11 min read

If you remember five things

The whole answer in one glance. Everything below defends these five calls.

  1. Nouns plus verbs. Your API is a set of resources (nouns) and HTTP methods act on them. Learn the pattern for one resource and you can guess every other one.
  2. Stateless. Each request carries everything the server needs, so any instance can serve any request. That is the foundation of horizontal scaling.
  3. Status codes do the talking. 2xx, 4xx, 5xx carry meaning so clients and proxies handle failures without parsing your error strings.
  4. Idempotency keys for unsafe retries. Networks drop responses and clients retry. A key on POST stops a retried create from charging twice or placing a duplicate order.
  5. Cursor pagination on anything that grows. Offset gets slow and shows duplicates when data shifts. A cursor stays fast and stable at any depth.

1The core idea#

REST stands for Representational State Transfer. Ignore the academic baggage. The model you actually need is simple.

Your API is a set of nouns, and HTTP verbs are the things you do to them.

  • A resource is a thing in your system: a user, an order, a playlist, a comment.
  • Each resource gets a URL that identifies it.
  • The verb you send says what you want to do to it.

So instead of inventing function names like createUser, deleteUserById, and getAllUsers, you have one noun, users, and you vary the verb:

  • GET /users lists users
  • POST /users creates one
  • GET /users/42 fetches user 42
  • PUT /users/42 replaces user 42
  • PATCH /users/42 updates part of user 42
  • DELETE /users/42 removes user 42

That is the whole trick: URLs name things, verbs act on them.

  • Once a client learns the pattern for one resource, they can guess it for every other resource in your API.
  • That predictability is the real value. A client team can integrate against /invoices on day one after learning /orders the week before.

Two more properties matter for interviews.

Statelessness: the server remembers nothing between requests.

  • Each request carries everything the server needs: auth token, parameters, body.
  • Any request can go to any instance, so you run 20 copies behind a load balancer with no sticky sessions and no shared memory.
  • This is the foundation of horizontal scaling.
  • Payoff: when one instance crashes mid-traffic, the load balancer stops sending it requests and nothing is lost, because no state lived on that box.

Uniform interface: same conventions everywhere.

  • Same verbs, same status code meanings, same naming across every endpoint.
  • A new engineer reads your route list and gets it in minutes, instead of learning that /fetchUserData and /user-get do almost the same thing.
The one idea the rest hangs on

Resources are nouns, HTTP methods are verbs. Everything else (status codes, pagination, versioning) is a convention layered on top. If you internalize noun-plus-verb, you stop translating function names into URLs and start designing APIs a client can predict.

2How it works#

A REST call is just an HTTP request and response. Here is the anatomy of one.

A client wants to create an order. It sends:

POST /orders
Authorization: Bearer <token>
Content-Type: application/json

{ "item_id": "sku_993", "quantity": 2 }

The server validates the token, checks the body, writes to the database, and replies:

201 Created
Location: /orders/7781
Content-Type: application/json

{ "id": 7781, "item_id": "sku_993", "quantity": 2, "status": "pending" }

What that response is doing:

  • 201 says a new resource was created, not just that the call succeeded.
  • Location tells the client where the resource now lives.
  • A well-behaved client stores /orders/7781 and polls it for status instead of guessing the URL.

Here is the path a typical request takes through a real deployment:

flowchart LR A[Client] --> B[API Gateway] B --> C[Load Balancer] C --> D[Order Service] D --> E[(Database)] D --> F[(Cache)]
The request path. Notice the division of labor: the gateway handles auth, rate limiting, and TLS; the load balancer spreads traffic across stateless instances; the service reads and writes data, hitting the cache first for reads when it can. Statelessness is what lets the load balancer send any request to any instance.

Status codes carry meaning. Use them properly and clients handle failures without parsing your error strings. The families:

  • 2xx success. 200 OK, 201 Created, 204 No Content for a delete with nothing to return.
  • 3xx redirection. 304 Not Modified for caching.
  • 4xx the client messed up. 400 bad request, 401 not authenticated, 403 authenticated but not allowed, 404 not found, 409 conflict, 429 too many requests.
  • 5xx the server messed up. 500 generic failure, 503 service unavailable.
Pause: a request arrives with a valid token, but the user is not allowed to touch that resource. Is that a 401 or a 403?
Reveal the answer
403. The rule: 401 means "I do not know who you are" (missing or bad credentials). 403 means "I know exactly who you are, and you still cannot do this." Valid token, wrong permission, is always 403. Mixing these up is the single most common status-code slip in interviews.

Idempotency is the property that makes distributed systems survivable.

  • An idempotent request produces the same result whether you send it once or five times.
  • GET, PUT, and DELETE are naturally idempotent: deleting order 7781 twice leaves it deleted either way.
  • POST is not, because two POSTs create two orders.
  • This matters because networks drop responses. A client that never got your 201 will retry, and now you have a duplicate order.
  • The fix is an idempotency key: a unique header the client sends (for example Idempotency-Key: a1b2c3) so the server recognizes the retry, dedupes it, and returns the original 201 the second time instead of creating a second order.

3A worked example: the write endpoint at real scale#

Put numbers on it so it stops being abstract. Say you are designing the order API for a mid-size storefront. Here is the back-of-the-envelope I would say out loud.

  • Traffic. 5 million orders per day averages to about 58 writes per second. Order traffic is bursty, so size for a peak near 5x the average: about 290 writes per second.
  • Reads dominate. Customers check order status far more than they place orders. Assume 20 reads per write: about 5,800 reads per second at peak. This is why I care so much about caching GET /orders/{id}.
  • Payload size. An order JSON is roughly 1 KB. At 290 writes/s that is about 290 KB/s inbound, trivial. Reads are the volume: 5,800 reads/s at 1 KB is about 5.8 MB/s, still small, but the cache hit rate is what keeps the database alive.
  • The retry math that justifies the key. At a 0.1% response-loss rate with client retries, 5 million orders a day means about 5,000 duplicate POST /orders attempts daily. Without a key, that is 5,000 duplicate orders a day. On a payments API, 5,000 double charges a day.
~5,000
Duplicate writes per day, with no idempotency keyOn 5M orders at a 0.1% lost-response rate. This single number is why the key is not optional on any unsafe, money-touching endpoint. Say it out loud in the room.

So the shape I would land on:

POST /orders            Idempotency-Key required, returns 201 + Location
GET  /orders/{id}       cacheable, served from cache on the hot path
GET  /orders?cursor=... cursor pagination, limit default 20 max 100
PATCH /orders/{id}      status transitions only

Why the caching line is the whole game:

  • At 5,800 reads/s, if the cache absorbs 90% of reads, the database sees about 580 reads/s instead of 5,800.
  • That is the difference between one modest Postgres primary with a read replica and a painful sharding conversation.
  • Saying this with the numbers is what separates "I know REST verbs" from "I have run this."

4Trade-offs#

This is where interviews get interesting. There is rarely one right answer, so explain what you give up.

PUT versus PATCH: replace the whole thing, or patch the fields you send.

  • PUT replaces the whole resource. Send a user object with only an email and you may wipe out their name, because you handed a partial body to a "replace" operation.
  • PATCH updates only the fields you send.
  • PUT is simpler and idempotent by nature. PATCH is lighter over the wire and safer for partial edits, but you have to be careful about its semantics.
  • I default to PATCH for updates in most real systems.
Decision Option A Option B When I pick which
Partial update PUT replaces whole resource PATCH updates sent fields PATCH for most updates, PUT only when the client truly owns the full object
Create semantics POST not idempotent PUT to a client-chosen id, idempotent PUT-with-id when the client can generate a stable id, otherwise POST plus idempotency key

Pagination styles: offset is easy, cursor is correct at scale.

  • When a list has a million rows, you cannot return all of them.
  • Offset is fine for a small admin table. For an infinite feed where new items arrive constantly, use cursors.
Fine for small tables
Offset
Looks like
?limit=20&offset=40
Pro
Simple, and you can jump to any page.
Con
Slow on deep pages, and items shift if data changes.
Breaks when
OFFSET 100000 walks and discards 100,000 rows before returning 20.
Default for feeds
Cursor
Looks like
?limit=20&cursor=abc
Pro
Stable and fast at any depth.
Con
Cannot jump to page 50, more work to build.
Wins because
A cursor on (created_at, id) is an indexed seek: page 5,000 costs the same as page 1.
  • Offset breaks on a live feed because inserting a row shifts everything and the user sees duplicates.
  • A cursor keyed on (created_at, id) turns deep pagination into an indexed seek that costs the same whether you are on page 1 or page 5,000.

Chatty versus chunky: small resources, or screen-shaped responses.

  • A strict REST design gives you small, focused resources.
  • But a mobile screen might need a user, their last five orders, and their cart. Three round trips on a slow connection feels sluggish.
  • At 200 ms per round trip on a weak mobile network, three sequential calls is 600 ms of spinner.
  • Fight it by letting clients expand related data (?include=orders,cart) or by adding a purpose-built endpoint for that screen.
  • The trade-off: more flexibility means a bigger API surface and harder caching. This is exactly the gap GraphQL was built to close, which is a great thing to mention.

Versioning: your API will change and you cannot break shipped clients overnight.

Approach Example Notes
URL path /v2/users Visible, easy to route and cache, most common
Header Accept: version=2 Cleaner URLs, harder to test by hand
  • I prefer URL versioning for public APIs: you can paste it in a browser and a CDN can cache different versions separately.
  • The honest answer: the best versioning is avoiding breaking changes. Add fields, do not remove or rename them.
  • A mobile app you shipped last year is still in someone's pocket, so the /v1 contract has to keep working for years.

5Where it is used#

REST is the default for public web APIs, and the examples are everywhere.

  • Payment providers expose POST /charges and GET /charges/ch_123.
  • Source control platforms expose GET /repos/owner/name/issues.
  • Cloud providers manage infrastructure through REST endpoints.

Reach for REST when:

  • You have clear resources that map to nouns: users, posts, orders, invoices.
  • Your clients are varied (web, mobile, third-party) and you want something predictable they can all consume.
  • You want HTTP caching, CDNs, and standard tooling. A GET with the right cache headers can be served from a CDN edge and never touch your servers. That is how you keep the 90% cache hit rate from the worked example honest.

Reach for something else when REST's shape fights your problem:

  • GraphQL when clients need wildly different slices of data and you are tired of either over-fetching or building a new endpoint per screen. This is the fix for chatty-versus-chunky.
  • gRPC for internal service-to-service calls where you want low latency, binary payloads, and a strict contract. Often 2x to 5x smaller payloads with protobuf, but harder to debug and not browser-friendly.
  • WebSockets when the server needs to push updates, like a live chat or a trading ticker. REST is request-response, so it cannot push.
From the interviewer's chair

A strong move is to default to REST for the public API and quietly use gRPC between your internal services. That is what a lot of real systems do, and naming the split unprompted tells me you have shipped both.

6Common pitfalls and gotchas#

Verbs in the URL.

  • POST /createUser or GET /getUserOrders defeats the point.
  • The verb belongs in the HTTP method, not the path. Keep URLs as nouns.

Returning 200 for everything.

  • I have seen APIs return 200 OK with a body like { "error": "not found" }.
  • Now every client parses the body to know if the call worked, and no proxy or monitoring tool can tell success from failure.
  • Your dashboards show 100% success while customers are failing. Let the status code do its job.

Non-idempotent retries.

  • A POST /payments with no idempotency key plus a network blip plus a client retry charges the customer twice.
  • Recall the math: 0.1% lost responses on millions of requests is thousands of double charges a day.
  • This is a real incident in real companies. Mention idempotency keys and you sound like someone who has been paged at 3am.

Leaky or inconsistent naming.

  • Pick a convention and hold it.
  • Plural nouns (/orders not /order), and either snake_case or camelCase for fields, not both.
  • Mixing them is the small thing that makes an API annoying to use.

No pagination on list endpoints.

  • GET /orders that returns every order ever created falls over the day a customer has 50,000 of them.
  • Paginate list endpoints from day one, even with a generous limit.

Exposing internal errors.

  • A 500 that dumps a stack trace or SQL error leaks your internals and helps attackers.
  • Return a clean error with a correlation ID the client can quote in a support ticket, and log the details server-side.
Red flag that fails you here

Returning 200 for failures, or shipping a POST /payments with no idempotency story. Both tell me you have never operated an API under real traffic. If I say "the client retried after a timeout" and you have no answer, the interview is effectively over.

7How it shows up in interviews#

When I ask someone to "design the API for X," here is what I am listening for.

Do they model resources cleanly?

  • Weak: rattles off getOrders, addOrder, updateOrderStatus.
  • Strong: "the resource is orders, so GET /orders, POST /orders, GET /orders/{id}, and for status I would use PATCH /orders/{id}."
  • The strong answer shows they get noun-plus-verb instead of translating function names into URLs.

Do they pick correct status codes?

  • Weak: "it returns a success or an error."
  • Strong: "201 on create with a Location header, 409 if the order already exists, 422 if the payload fails validation."
  • Specific codes signal real experience.

Do they handle scale and failure?

  • This is where beginners and strong candidates separate.
  • Bring up pagination before I ask. Bring up idempotency keys on the payment endpoint. Mention rate limiting with 429 and a Retry-After header.
  • Each is a one-sentence tell that you have run APIs in production, not just read about them.

Can they defend a trade-off?

  • If I push with "why REST and not GraphQL here?", a strong answer is: "the clients all need roughly the same shape of data and I want CDN caching on reads, so REST is simpler. If the mobile team started needing very different slices, I would reconsider."
  • That is a real engineer weighing options, not reciting a definition.

Here is the contrast boiled down, because it is the whole game:

Weak: "I would have an endpoint /getOrders to get orders and /addOrder to add one. It returns the order, or an error if something goes wrong."

Strong: "The resource is orders. POST /orders with an Idempotency-Key header returns 201 plus a Location, and the key stops a retried request from creating a duplicate. GET /orders/{id} is cacheable and serves most of my read traffic from cache, which at a 20-to-1 read-write ratio is what keeps the database off the floor. List is GET /orders?cursor=... with cursor pagination so deep pages stay fast. Status changes go through PATCH /orders/{id}, and I return 409 on a conflicting transition."

  • Same question. The second answer names verbs, codes, headers, a caching strategy, and a failure mode without being asked.
  • The through-line: be specific, name the verbs and codes, and surface the failure cases on your own.

Name the verb, name the code, name the failure. That is the difference between an answer that passes and one that stands out.

Follow-ups the interviewer will ask (and your answers)
"The client sent POST /orders, timed out, and retried. How do you avoid a duplicate?"
An idempotency key. The client sends a unique `Idempotency-Key` header. The server stores the result keyed by it, and a retry with the same key returns the original `201` instead of creating a second order. On a payments API this is the difference between one charge and two.
"Offset or cursor pagination for an order feed, and why?"
Cursor. New orders arrive constantly, so offset shifts rows and shows duplicates, and `OFFSET 100000` walks and discards 100,000 rows before returning 20. A cursor on `(created_at, id)` is an indexed seek that costs the same on page 1 or page 5,000.
"401 or 403: valid token, but the user is not allowed to touch this resource?"
403. 401 is "I do not know who you are" (missing or bad credentials). 403 is "I know who you are and you still cannot do this." Valid token plus wrong permission is always 403.
"PUT or PATCH to update one field on a user?"
PATCH. PUT replaces the whole resource, so sending a partial body can wipe fields you did not include. PATCH updates only what you send. I default to PATCH for updates and keep PUT for cases where the client truly owns the full object.
"Why REST here instead of GraphQL or gRPC?"
REST for the public API because clients need a similar shape and I want CDN caching on reads. GraphQL if clients start needing very different slices and I am over-fetching or building an endpoint per screen. gRPC between internal services for low latency and a strict contract. Different shapes for different problems.

What you can now explain in the room

  • Why REST is just resources (nouns) plus HTTP methods (verbs), and why that predictability is the point.
  • Why statelessness is the thing that lets you scale horizontally behind a load balancer.
  • Which status code to return when, including the 401-versus-403 distinction.
  • Why POST needs an idempotency key, with the retry math that proves it.
  • When to use cursor pagination instead of offset, and why offset dies on deep pages.
  • How to defend REST against GraphQL and gRPC, and when you would switch.

Next, try the System Design Question walkthrough on designing a URL shortener, where you will put these endpoints to work on a real resource.

Get a prep plan built around your REST API Design interview

Tell us the role and your interview date. We will send back a dated, day by day plan. Free.

Thanks. Check your inbox, your REST API Design prep plan is on the way.