Versioning & Pagination
URL vs header API versioning, and offset vs cursor-based pagination at scale.
Why APIs need versioning
An API is a contract. The moment another team, another company, or a public developer starts depending on your endpoints, changing the shape of a response or removing a field can break every client that consumes it — often without any warning until something quietly stops working in production. Versioning is how you evolve an API without breaking existing consumers overnight.
Not every change needs a new version — adding a new, optional field to a response is usually safe ("additive" changes rarely break well-written clients). A new version is for breaking changes: renaming or removing a field, changing a field's type, or changing what a status code means.
URL path versioning
The most common and most explicit approach — the version is baked directly into the URL:
GET https://api.example.com/v1/users/42
GET https://api.example.com/v2/users/42
Pros: impossible to miss, trivially cacheable per version, easy to route at the infrastructure level (a load balancer or API gateway can route /v1/* and /v2/* to entirely different backend deployments).
Cons: technically, the URL is supposed to identify a resource, not a format of that resource — /v1/users/42 and /v2/users/42 arguably refer to the same underlying user, just represented differently. This is a common purist objection, but in practice URL versioning remains the most widely used approach because of its simplicity and visibility.
Header-based versioning
The version is specified in a request header instead of the URL, leaving the URL itself stable:
GET /users/42
Accept: application/vnd.example.v2+json
or a custom header:
GET /users/42
X-API-Version: 2
Pros: the resource's URL stays canonical and unchanged across versions — arguably more "correct" REST.
Cons: much less visible — you can't tell which version an API call targets just by glancing at a URL in a browser tab, a log line, or a bug report. Requires every client to remember to set the header correctly, and mistakes fail more silently (falling back to a default version instead of a clear 404).
| Approach | Visibility | Caching | Purity |
|---|---|---|---|
URL path (/v1/…) |
High — visible everywhere | Simple, per-version caching | Debated, but dominant in practice |
Header (Accept/custom) |
Low — hidden in request metadata | Requires cache keys to include the header | Closer to REST's "URL identifies a resource" ideal |
In practice, most public APIs (Stripe, GitHub, Twilio) use some form of URL-path or explicit header versioning, and URL-path remains the more common default for new APIs due to its simplicity.
Pagination: why it's necessary
Returning every row of a users table in one response doesn't scale — at some size, the response becomes too large to transfer or render efficiently, and the database query to produce it becomes too slow. Pagination breaks a large collection into smaller pages the client requests one at a time.
Offset/limit pagination
The simplest and most familiar pattern — the client says how many items to skip (offset) and how many to return (limit):
GET /products?offset=40&limit=20
{
"data": [ /* 20 products, starting at the 41st */ ],
"total": 3214,
"offset": 40,
"limit": 20
}
This maps naturally onto SQL:
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 40;
The problem at scale: OFFSET doesn't skip efficiently — the database still has to scan and discard all the skipped rows internally, so OFFSET 100000 LIMIT 20 gets slower as the offset grows, even though the client only wants 20 rows. Offset pagination is also unstable under concurrent writes: if a row is inserted or deleted between two page requests, items can shift, causing a client to see the same item twice or skip one entirely.
Cursor-based pagination
Instead of a numeric offset, the client passes an opaque cursor — typically an encoded pointer to the last item it saw (often based on a unique, sortable column like id or created_at):
GET /products?cursor=eyJpZCI6MTQyMH0&limit=20
{
"data": [ /* 20 products, after the one the cursor points to */ ],
"next_cursor": "eyJpZCI6MTQ0MH0"
}
The underlying query uses the cursor's value directly as a filter, not a row-count to skip:
SELECT * FROM products WHERE id > 1420 ORDER BY id LIMIT 20;
Because WHERE id > 1420 is an indexed lookup rather than a scan-and-discard, this stays fast regardless of how deep into the collection the client pages — page 5 and page 5,000 cost roughly the same. It's also stable under concurrent inserts/deletes elsewhere in the table, since each page is defined relative to a specific row, not a shifting numeric position.
| Offset/limit | Cursor-based | |
|---|---|---|
| Can jump to an arbitrary page (e.g. "page 50") | Yes | No — only forward/backward from a cursor |
| Performance on large tables | Degrades as offset grows | Stays roughly constant |
| Stable under concurrent writes | No — items can shift between pages | Yes |
| Implementation simplicity | Very simple | Slightly more work (encoding/decoding cursors) |
Cursor-based pagination is the standard choice for large, high-throughput, or frequently-changing collections (social media feeds, activity logs, any "infinite scroll" UI) — it's what Twitter/X, GitHub, and Stripe's APIs all use for their larger collections. Offset/limit remains perfectly reasonable for smaller, mostly-static collections, or UIs that genuinely need numbered page links ("1 2 3 ... 10").
Common mistakes
- Using offset pagination on a large, frequently-written-to table and being surprised when users report seeing duplicate or missing items while paging through a live feed.
- Exposing raw, guessable cursors (like a plain row ID) instead of an opaque, encoded token — clients shouldn't be able to construct or manipulate cursors themselves.
- Making a breaking API change (renaming a field, changing a type) without bumping the version, silently breaking every existing client.
Interview questions
Q: Why does offset-based pagination get slower on large tables?
Because the database still has to scan through and discard every row before the offset internally, even though none of those rows are returned — OFFSET 100000 means "compute and skip 100,000 rows," which gets more expensive as the offset grows, unlike a cursor-based WHERE id > X filter, which uses an index lookup regardless of position.
Q: Why is cursor-based pagination more stable when items are being inserted or deleted concurrently? Because each page is defined relative to a specific item (via the cursor), not a shifting numeric position. If a row is inserted before the client's current position under offset pagination, every subsequent page shifts by one, causing skipped or duplicated items — a cursor doesn't have this problem, since it always resumes exactly "after this specific item."
Q: What's the main trade-off cursor-based pagination makes compared to offset pagination? It gives up the ability to jump to an arbitrary page number (like "go to page 50") — you can only page forward or backward relative to a cursor — in exchange for consistent performance and stability at scale. This trade-off is usually worth it for large or frequently-changing collections, but not necessary for small, mostly-static ones.