API Design: REST, gRPC, Pagination & Versioning
By the end of this lesson you will be able to design a clear REST API for a feature, choose correct methods and status codes, paginate large lists without duplicates, change an API without breaking old apps, and decide when gRPC suits better than REST.
What an API is
An API (application programming interface) is the contract between two programs: which requests are allowed, what they must contain and what comes back. In system design, the API is usually the first concrete thing you write after the requirements, because it shows exactly what the system must do.
Think of the menu at a restaurant in Lucknow. You do not walk into the kitchen; you order from the menu. The menu is the API. A good menu is clear, does not change the meaning of "Dal Makhani" every week, and tells you politely when something is not available. The same qualities make a good API.
REST: resources and methods
REST is a style for HTTP APIs built around resources: the nouns of your system, each with a URL. You act on them with standard HTTP methods.
| Method | Meaning | Example | Idempotent? |
|---|---|---|---|
| GET | Read | GET /restaurants/42/menu | Yes |
| POST | Create, or run an action | POST /orders | No |
| PUT | Replace entirely | PUT /users/77/address | Yes |
| PATCH | Change some fields | PATCH /orders/8812 | Not guaranteed |
| DELETE | Remove | DELETE /cart/items/5 | Yes |
Idempotent means doing it twice has the same effect as doing it once. Deleting an item twice leaves it deleted. Creating an order twice creates two orders. This matters a lot when networks fail and clients retry, which is the subject of Async Processing & Idempotency.
Use nouns in paths and let the method be the verb: POST /orders, not POST /createOrder. Nest only one level when it shows ownership: /restaurants/42/reviews.
Status codes and errors
Status codes tell the client what happened without reading the body.
| Code | Meaning | When |
|---|---|---|
| 200 OK | Success with a body | A read or update |
| 201 Created | New resource made | After POST /orders |
| 204 No Content | Success, nothing to return | After a delete |
| 400 Bad Request | The request is malformed | Missing field, wrong type |
| 401 Unauthorized | Not logged in | Missing or expired token |
| 403 Forbidden | Logged in but not allowed | Viewing someone else's order |
| 404 Not Found | No such resource | Wrong order ID |
| 409 Conflict | Clashes with current state | Seat already booked |
| 429 Too Many Requests | Rate limit hit | See Rate Limiting |
| 500 / 503 | Server problem / temporarily unavailable | Bug, overload, maintenance |
Return errors in one consistent shape. The IETF standard "Problem Details for HTTP APIs" (RFC 9457) defines a common JSON format:
POST /v1/bookings HTTP/1.1
Host: api.railyatra.example
Content-Type: application/json
Idempotency-Key: 7f3c9a10-4b2e-4d6f-9c1a-2b8e5d0f1a77
{"train_no": "12951", "date": "2026-11-20", "class": "3A", "passengers": 2}
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://api.railyatra.example/problems/no-seats",
"title": "No seats available",
"status": 409,
"detail": "Class 3A on train 12951 for 2026-11-20 is full. Waitlist is open.",
"waitlist_available": true
}Pagination: offset vs cursor
Lists can be huge, so APIs return them in pages. There are two common styles.
Offset pagination says "skip 40, give me 20": GET /orders?offset=40&limit=20. It is simple and allows jumping to page 7. But it has two problems. The database must still walk past all skipped rows, which gets slow deep into a list. And if new items arrive while a user is scrolling, items shift and the user sees duplicates or misses some.
Cursor (keyset) pagination says "give me 20 items after this one": GET /orders?after=<cursor>&limit=20. The cursor encodes the position of the last item seen, such as its timestamp and ID. The database jumps straight there using an index, and new items at the top do not shift anything.
Step 1: run the simulation. A user reads page 1 of a feed, then three new posts arrive, then the user asks for page 2.
feed = [f"post-{i}" for i in range(100, 90, -1)] # newest first: post-100 ... post-91
def offset_page(items, offset, limit):
return items[offset:offset + limit]
def cursor_page(items, after, limit):
start = items.index(after) + 1 if after else 0
return items[start:start + limit]
page1 = offset_page(feed, 0, 4)
cursor = page1[-1]
print("Page 1 :", page1)
feed = ["post-103", "post-102", "post-101"] + feed # 3 new posts arrive
print("Page 2 (offset) :", offset_page(feed, 4, 4))
print("Page 2 (cursor) :", cursor_page(feed, cursor, 4))Page 1 : ['post-100', 'post-99', 'post-98', 'post-97'] Page 2 (offset) : ['post-99', 'post-98', 'post-97', 'post-96'] Page 2 (cursor) : ['post-96', 'post-95', 'post-94', 'post-93']
Step 2: compare. Offset pagination repeated three posts the user had already seen (post-99 to post-97), because everything shifted down by three. The cursor continued exactly where the user stopped. In SQL, a cursor page is a keyset query:
SELECT id, created_at, text
FROM posts
WHERE (created_at, id) < ('2026-11-02 10:15:00', 98812)
ORDER BY created_at DESC, id DESC
LIMIT 20;Clients should treat the cursor as an opaque string they pass back unchanged. Encoding it (for example as base64 of the timestamp and ID) lets you change its contents later without breaking anyone.
import base64, json
def make_cursor(created_at, item_id):
raw = json.dumps({"t": created_at, "id": item_id}).encode()
return base64.urlsafe_b64encode(raw).decode()
def read_cursor(cursor):
return json.loads(base64.urlsafe_b64decode(cursor.encode()))
c = make_cursor("2026-11-02T10:15:00Z", 98812)
print("next_cursor:", c)
print("decoded :", read_cursor(c))next_cursor: eyJ0IjogIjIwMjYtMTEtMDJUMTA6MTU6MDBaIiwgImlkIjogOTg4MTJ9
decoded : {'t': '2026-11-02T10:15:00Z', 'id': 98812}Versioning without breaking clients
Mobile apps stay installed for months. If you rename a field, old apps crash. So:
- Prefer additive changes. Adding a new optional field or a new endpoint does not break anyone. Clients must ignore fields they do not know.
- Never change the meaning of an existing field. If
pricewas in rupees, do not make it paise. Addprice_paiseinstead. - When you must break, make a new version. Put it in the path (
/v2/orders) or a header. Path versions are the easiest to see in logs and docs. - Deprecate slowly. Announce the end date, measure who still calls the old version, and send a
Sunsetheader (RFC 8594) with the shutdown date.
gRPC and other styles
gRPC is a framework for calling functions on another service. You describe messages and methods in a .proto file using Protocol Buffers, a compact binary format, and tools generate client and server code in many languages. gRPC runs over HTTP/2 and supports streaming in both directions.
syntax = "proto3";
service Dispatch {
rpc FindDriver (RideRequest) returns (DriverOffer);
rpc TrackRide (RideId) returns (stream Location); // server streams updates
}
message RideRequest {
string rider_id = 1;
double pickup_lat = 2;
double pickup_lng = 3;
}| REST + JSON | gRPC + Protobuf | |
|---|---|---|
| Format | Text, human-readable | Binary, smaller and faster to parse |
| Contract | Optional (OpenAPI) | Required .proto file |
| Browser support | Native | Needs a proxy (gRPC-Web) |
| Streaming | Limited (SSE, WebSockets) | Built in |
| Best for | Public APIs, mobile and web clients | Internal service-to-service calls |
GraphQL is a third option: the client sends a query describing exactly which fields it wants, and one endpoint returns them. It suits apps whose screens need data from many sources, at the cost of harder caching and the need to protect the server from very expensive queries.
Common mistakes
- Verbs in URLs (
/getOrders,/deleteUser) instead of methods on resources. - Returning 200 with
{"error": ...}in the body, which hides failures from clients, proxies and monitoring. - Offset pagination on large, changing lists, causing slow queries and duplicate items.
- Breaking changes without a new version, crashing old mobile apps.
- Unbounded list endpoints with no maximum
limit.
Key points
- REST models resources as URLs and uses HTTP methods as verbs; GET, PUT and DELETE are idempotent, POST is not.
- Use precise status codes and one consistent error format such as RFC 9457 problem details.
- Cursor pagination is fast and stable on changing lists; offset pagination is simple but shifts and slows down.
- Evolve APIs with additive changes, version only for breaking changes, and deprecate slowly.
- gRPC with Protocol Buffers suits internal, high-volume calls and streaming; REST suits public clients.
Exercise
Design the REST endpoints for a "college library" service: list books with search and pagination, view one book, borrow a book, return a book, and list a student's current loans. For each, give the method, path, a success status code and one likely error status code. Write them as a Python list of tuples and print them as a table.
Show solution
endpoints = [
("GET", "/v1/books?q=&after=&limit=", 200, 400),
("GET", "/v1/books/{book_id}", 200, 404),
("POST", "/v1/loans", 201, 409), # body: book_id; 409 if no copy free
("DELETE", "/v1/loans/{loan_id}", 204, 404), # returning ends the loan
("GET", "/v1/students/{student_id}/loans", 200, 403),
]
for method, path, ok, err in endpoints:
print(f"{method:6} {path:34} {ok} {err}")GET /v1/books?q=&after=&limit= 200 400
GET /v1/books/{book_id} 200 404
POST /v1/loans 201 409
DELETE /v1/loans/{loan_id} 204 404
GET /v1/students/{student_id}/loans 200 403Borrowing creates a loan resource, so it is a POST that returns 201; returning a book deletes the loan. A student viewing another student's loans gets 403. Some teams model a return as PATCH /v1/loans/{id} with {"returned": true} to keep the loan history; both are reasonable if you are consistent.
Quick Quiz
1. Which HTTP method is NOT idempotent?
Sending the same POST twice usually creates two resources.
2. Why does offset pagination show duplicates on a busy feed?
Skipping N items skips different items once new ones are inserted.
3. Which change is safe for old mobile clients?
Additive changes do not break clients that ignore unknown fields.
4. Where is gRPC usually the best fit?
Binary messages, generated code and HTTP/2 streaming suit internal traffic.
Learn System Design with a mentor
Free lessons get you started. Our Computer Science Fundamentals training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
