Module 2 / One server, one database

Module 2 · lesson 4 of 9 · Read 8 min

Pagination, errors and versioning

The small API decisions that are painful to change later.

The last lesson sketched Tiffin's endpoints: what resources exist and which verbs act on them. That's the part everyone thinks about.

This lesson is about three smaller decisions that nobody thinks about until it's too late: how to return long lists, how to report errors, and how to change the API once apps are already using it. Each is easy to get right on day one and miserable to fix on day 400, when old app versions depend on the wrong choice.

Pagination: never return everything

A customer opens "My orders". On day one they have three orders, so the server returns all three. Two years later, a loyal customer who orders lunch every weekday has 500. The response is now 400 KB, the query reads 500 rows, and the screen only shows the first 10.

The fix is pagination: return a page at a time, and let the client ask for more. Every endpoint that returns a list that can grow should be paginated from the start. Adding it later is a breaking change, because old apps expect to get everything in one go.

There are two common ways to do it.

Offset pagination

The client says how many to skip:

GET /me/orders?limit=20&offset=40
SELECT * FROM orders
WHERE customer_id = :me
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;

It's simple, and it lets you jump to "page 7". But it has two problems.

It gets slower the deeper you go. OFFSET 10000 still makes the database find and throw away 10,000 rows before returning 20. For a customer's own orders that rarely matters. For an admin screen paging through every order in the city, it does.

It skips and repeats rows when data changes. Picture the kitchen tablet scrolling through today's orders, newest first. It loads page one (orders 1 to 20). While the cook is reading, three new orders arrive. Now it loads page two with offset=20, but everything has shifted down by three, so orders 18, 19 and 20 appear again at the top of page two.

Cursor pagination

Instead of "skip 40", the client says "continue after the last thing I saw":

GET /me/orders?limit=20
{
  "orders": [ ... 20 orders ... ],
  "next_cursor": "eyJjIjoiMjAyNi0xMC0xMlQxOToyMjowMFoiLCJpZCI6ODgxMn0"
}
GET /me/orders?limit=20&cursor=eyJjIjoiMjAyNi0xMC0xMlQxOToyMjowMFoiLCJpZCI6ODgxMn0

The cursor is just the sort position of the last row, encoded so the client treats it as an opaque token. Decoded, it says created_at = 2026-10-12T19:22:00Z, id = 8812. The server turns it into:

SELECT * FROM orders
WHERE customer_id = :me
  AND (created_at, id) < ('2026-10-12 19:22:00', 8812)
ORDER BY created_at DESC, id DESC
LIMIT 20;

With an index on (customer_id, created_at, id), the database jumps straight to the right spot. Page 500 is as fast as page 1, and new rows arriving at the top don't shift anything.

Note the id in there. Two orders can have the same created_at, so we add a unique column to break ties. Without it, rows with equal timestamps can be skipped at a page boundary.

Offset Cursor
Jump to page N Yes No, only next (and previous)
Deep pages Slow Fast
Rows inserted while paging Duplicates or gaps Stable
Good for Small admin tables, numbered pages Feeds, order histories, infinite scroll

For Tiffin, every list in the apps scrolls, and nobody needs "jump to page 37". So we use cursors everywhere. Also: always set a maximum limit on the server (say 50). Otherwise someone will eventually send limit=1000000.

Errors: make them useful to code and to people

When something goes wrong, the status code says what kind of problem it is. But the app usually needs more. "422" doesn't tell the customer whether the restaurant closed, a dish sold out, or the coupon expired.

Pick one error format for the whole API and stick to it:

{
  "error": {
    "code": "item_unavailable",
    "message": "Chicken dum biryani is sold out.",
    "details": { "menu_item_id": 7 },
    "request_id": "req_7f3a9c21"
  }
}

Each field has a job:

  • code is for the app's code. It's a stable string the app can switch on: show the "sold out" sheet, ask the user to log in again, and so on. Never make the app parse message.
  • message is for humans. A developer reading logs, or (if you're careful about wording) the customer.
  • details carries whatever the app needs to act: which dish, which field failed validation.
  • request_id is a unique id for this one request, also written to the server's logs. When a customer complains, support can find exactly what happened.

Two more rules:

  1. Don't leak internals. A database error message or a stack trace in the response tells attackers what you run and how. Log the details on the server; send a generic internal_error to the client.
  2. Treat the list of codes as part of the API. Once the app switches on item_unavailable, renaming it to dish_unavailable breaks every app in the wild.

Versioning: the API outlives the app

Here's the hard truth of mobile apps: people don't update. A meaningful share of Tiffin's customers will be running an app that's six months or more old, and some will be on versions from years ago. Every one of those versions is calling your API, expecting the responses it was written for.

So the main rule of evolving an API is:

Make changes that old clients won't notice. Version only when you truly can't.

Changes that are safe

  • Adding a new endpoint. Old apps never call it.
  • Adding a new field to a response. Old apps ignore fields they don't know (make sure your apps are written to do that).
  • Adding a new optional field to a request. Old apps don't send it; the server uses a default.

Changes that break old clients

  • Removing or renaming a field.
  • Changing a field's type (price from a number to a string) or meaning (rupees to paise).
  • Making an optional request field required.
  • Adding a new value to something the app treats as a closed list. If an old app has a switch over order statuses and the server suddenly sends "rider_arriving", the old app may crash or show nothing. Design apps to handle unknown values gracefully, with a sensible fallback.

When you must break things

Sometimes a change really is incompatible. Then you run two versions side by side. The most common, most visible way is in the URL:

GET /v1/restaurants/42/menu
GET /v2/restaurants/42/menu

Some APIs put the version in a header instead (Accept-Version: 2, or a date like Tiffin-Version: 2026-10-01). Headers keep URLs clean; URL versions are easier to see in logs and to test in a browser. Either works. What matters is that you decide on day one, so that version one already has a version number. Adding /v1 later means every existing app is on an unversioned API you can never change.

Running two versions has a cost: two code paths to maintain and test. So also plan how to retire old versions. Track which app versions call which API version, and when usage of the old one is low enough, have the server tell those apps to update. Most mobile apps include a "minimum supported version" check at startup for exactly this.

Safe or breaking?

For each change to Tiffin's API, say whether it's safe for old apps, and if not, how you'd do it without breaking them: (a) add "estimated_minutes": 35 to the order response, (b) change price_paise from an integer to an object {"amount": 28000, "currency": "INR"} to support a second country, (c) make delivery_instructions required when placing an order, (d) add a new order status "rider_arriving".

Show one answer

(a) Safe. A new field in a response is ignored by old apps.

(b) Breaking: same name, different type. Instead, keep price_paise as it is and add a new field like price with the object. Old apps keep reading the old one. New apps read the new one. Remove the old field only in a new API version once old apps are gone.

(c) Breaking: old apps don't send it, so every order from them would fail. Keep it optional and have the server default to an empty string. If the business really needs it, show the field in new app versions only.

(d) Risky. It's "just a new value", but any old app that assumed a fixed list of statuses might break. Only send it to app versions that know about it, or map it to the nearest old status ("picked_up") for older versions.

What to remember

  1. Paginate every list that can grow, from day one. Adding it later is a breaking change.
  2. Prefer cursor pagination for feeds and histories: fast at any depth and stable while new rows arrive. Use a unique tiebreaker column, and cap the page size.
  3. Use one error format everywhere, with a stable machine-readable code, a human message, useful details and a request_id.
  4. Old app versions live for years. Prefer changes they won't notice: new endpoints, new response fields, new optional request fields.
  5. Version the API from day one, and plan how to retire old versions.