Course outline · 0% complete

0/29 lessons0%

Course overview →

Pagination and Versioning

lesson 4-2 · ~10 min · 12/29

Never return everything

GET /posts on a real app could mean two million rows. Sending them all would crush the database, the network, and the client. So list endpoints paginate: they return one page at a time.

The common style is page-based: GET /posts?page=2&perPage=20. The math to slice a page out of a list:

start = (page − 1) × perPage
end   = start + perPage

Page 1 of 20 covers items 0 to 19, page 2 covers 20 to 39, and so on. It is worked below on a small array.

Slicing a page out of a list

Seven items at three per page means pages 1 and 2 are full and page 3 holds the remainder.

const items = ["a", "b", "c", "d", "e", "f", "g"];

function pageOf(items, page, perPage) {
  const start = (page - 1) * perPage;
  return items.slice(start, start + perPage);
}

console.log(pageOf(items, 1, 3).join(","));
console.log(pageOf(items, 2, 3).join(","));
console.log(pageOf(items, 3, 3).join(","));

Output

a,b,c
d,e,f
g

Page 3 starts at index (3 - 1) * 3, which is 6, and slice returns a short final page without complaint. That forgiving behavior is why no special case is needed for the last page.

The page - 1 is where off-by-one bugs live. Pages are numbered from 1 for humans and arrays are indexed from 0, so the subtraction converts between the two, and omitting it silently skips the first page.

Asking for page 4 returns an empty array rather than an error, which is a reasonable answer and one the client has to be prepared for. Real handlers usually validate the page number so a typo produces a 400 instead of a mysteriously empty list.

Note that slice on an in-memory array is the teaching version. A real endpoint pushes the same arithmetic into SQL as LIMIT 3 OFFSET 6, because loading two million rows to slice three of them defeats the purpose.

Tell the client where it stands

A bare array is a rude answer, because the client cannot render "Page 2 of 34" or know whether to show a Next button. Good list endpoints wrap the data with meta:

{
  "data": ["d", "e", "f"],
  "meta": { "page": 2, "perPage": 3, "total": 7, "totalPages": 3 }
}

totalPages is Math.ceil(total / perPage), rounding up so a leftover half-page still counts.

Versioning

Once someone depends on your API, changing a response shape breaks their app. The standard escape hatch is a version prefix: /v1/posts today, and when you must change shapes, /v2/posts alongside it while v1 keeps working. Version from day one, retrofitting is painful.

Renaming a field that clients depend on

When production mobile apps read "name" and you want "firstName" and "lastName", the safe move is to ship the new shape under /v2 while /v1 keeps returning "name".

Removing or renaming a field is a breaking change, and you do not control when users update their apps. An app installed last year keeps making the same requests forever, so the old shape has to keep working.

Running /v2 with the new shape while /v1 stays intact lets every client migrate on its own schedule. The cost is maintaining two response shapes for a while, which is far cheaper than the alternative of breaking installed apps.

Purely additive changes, such as adding a new optional field, are safe without a version bump. Clients ignore fields they do not know about, so adding is compatible and removing or renaming is not.

ChangeBreaking
add an optional fieldno
rename a fieldyes
remove a fieldyes
change a field's typeyes
add a new endpointno
make an optional input requiredyes

The practical version of this rule is to version from day one, since retrofitting is painful. Shipping /v1/posts on the first day costs four characters, and adding a prefix after clients exist means the unprefixed routes have to be supported anyway.

Wrapping a page with meta

paginate(items, page, perPage) returns the page's data together with the numbers a client needs to render its controls.

function paginate(items, page, perPage) {
  const start = (page - 1) * perPage;
  return {
    data: items.slice(start, start + perPage),
    meta: {
      page: page,
      perPage: perPage,
      total: items.length,
      totalPages: Math.ceil(items.length / perPage),
    },
  };
}

const result = paginate(["a", "b", "c", "d", "e", "f", "g"], 2, 3);
console.log(result.data.join(","));
console.log(JSON.stringify(result.meta));

Output

d,e,f
{"page":2,"perPage":3,"total":7,"totalPages":3}

total is the full item count rather than the page's length, which is the number that lets a client say "7 results". totalPages uses Math.ceil so the leftover single item still counts as page 3, and Math.floor would report 2 and hide the last item.

The key order in meta determines the printed JSON, since JSON.stringify preserves insertion order. That is worth knowing for matching expected output and is not something a client should ever depend on.

Wrapping the array in an object is the part that matters for API design. A bare array leaves no room for meta, so adding pagination information later would be a breaking change, and the wrapper is cheap insurance on day one.

Note that computing total from items.length works here because the whole list is in memory. Against a database it is a second query, a COUNT(*), which is why some APIs offer cursor-based pagination that skips the count entirely.