bigonotes
System DesignAPI Design

Pagination and Versioning

Offset vs cursor pagination, URL vs header versioning, and how to retire old API versions without breaking clients.

6 min readLast updated API Design

Two problems show up in almost every API that lives long enough. Lists grow too big to return at once, so you need pagination. The API has to change without breaking existing clients, so you need versioning. Both are quick to mention in an interview, and both have trade-offs worth knowing.

Pagination#

Pagination splits a large result into pages. Sending all one million comments on a viral post in one response would overwhelm the client, burn the user's data plan and hammer the database, so you send around 20 at a time.

Offset pagination skips rows and can show duplicates when new rows are inserted; cursor pagination continues after the last item seen

Offset-based#

GET /comments?limit=20&offset=40 skips 40 items and returns the next 20 (page 3).

SELECT * FROM comments
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;
  • Items shift. If a new comment is added while you're on page 2, everything moves down by one, and page 3 repeats an item you've already seen. Deletions cause skipped items.
  • Deep pages get slow. At offset=500000, the database still walks past 500,000 rows to skip them.

Offset is still the right choice when:

  1. Users need numbered navigation: "Go to page 4" or "Showing 1–20 of 5,000". A cursor only knows next and previous.
  2. The data is mostly static, like a list of countries or historical records, so nothing shifts.
  3. The dataset is small. Skipping 400 of 500 rows is instant, and offset is far simpler to build.
Use case Why offset fits
Search results Users jump to page 2 or 3
Admin dashboards Easy to sort by any column and jump to a page
E-commerce listings "Go to last page" is common
Small internal tools Minutes to build instead of hours

Cursor-based#

GET /comments?limit=20&cursor=abc123. The cursor encodes the position of the last item you saw, often its id or timestamp. The next page is "items after that":

-- cursor decodes to the last seen (created_at, id);
-- id breaks ties
SELECT * FROM comments
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;
  • Stable under inserts. New items don't shift your position, so there are no duplicates. That makes it ideal for infinite scroll on feeds, chats and comments.
  • Fast at any depth. With an index on the sort key, each page is an index seek (O(log⁡N)O(\log N)), whether it's the first page or the ten-thousandth.
  • Downside: no jumping to page 50. You can only go forward or back.

Feeds at companies like Facebook, Twitter/X and Slack use cursor pagination.

Offset Cursor
Performance Slower the deeper you go Consistently fast
Changing data Duplicates or skips Handles inserts cleanly
UX Numbered pages Infinite scroll
Implementation Very easy (LIMIT/OFFSET) Needs a stable, indexed sort key

In an interview: for a high-volume, constantly updating feed, choose cursor pagination and explain that offset would show duplicates as new items arrive, while cursors stay fast however far the user scrolls. For a small admin tool that needs page numbers and sorting by any column, offset is simpler and good enough.

Versioning#

APIs change, and public APIs especially can't force clients to update on your schedule. You need a way to ship breaking changes without breaking the clients that already exist.

URL versioning (the default)#

https://api.example.com/v1/events → /v2/events

  • Visible: the version shows up in logs, browser history and bug reports.
  • Cache-friendly: browsers and CDNs treat /v1/ and /v2/ as different resources, so a v2 client never gets cached v1 data.
  • Easy to route: a load balancer or gateway (e.g. NGINX) can send /v1/ to old servers and /v2/ to new ones.

Header versioning#

GET /events with API-Version: 2 (or Accept-Version: v2, or a custom X-API-Version).

  • Keeps URLs clean and fits the REST idea that a resource's identity shouldn't change when the code does.
  • But it's hidden: paste the URL into a browser and you may get the wrong version. Caches need Vary: API-Version to keep versions apart.
URL versioning Header versioning
Discoverability High Low
Caching Simple Needs Vary
Implementation Simplest More logic
Common for Public APIs (Stripe, Twilio) Internal APIs

When to bump the version#

Only for breaking changes:

Change Breaking? Example
Add an optional field No, stay on v1 New subtitle field
Rename a field Yes, move to v2 event_name → title
Change a type Yes A string field becomes an integer
Remove an endpoint Yes Deleting /events/{id}/legacy

Retiring an old version#

Turning off v1 can break every client still using it, so retirement is a process, not a switch.

Standard lifecycle (public or non-critical APIs)#

  1. Deprecate. Announce v2 and mark v1 deprecated in the docs.
  2. Sunset header. Add a Sunset header (the shutdown date) and a link to the migration guide on every v1 response, so clients' automated tooling notices even if nobody reads email.
  3. Brownouts. Briefly make v1 fail on purpose (return 410 Gone or 503) for an hour, then longer windows over several weeks, so teams that ignored the warnings notice and migrate. It's a real but aggressive practice. Describe it carefully in an interview, since it deliberately breaks clients.
  4. Watch the tail. Track which clients still call v1 (via the gateway or load balancer). Reach out to big ones directly, and wait until traffic is near zero.
  5. Remove it. Delete the route, delete the v1 code, and shut down any dedicated v1 servers.
Phase Action Example timing
Deprecation Announce v2, mark v1 deprecated Day 0
Sunset Sunset headers on all v1 responses Months 1–3
Brownout Short, planned v1 failures Month 5
Removal Remove routing and code Month 6

Zero-breakage options (critical systems)#

When a failed request is unacceptable (banking, healthcare, core infrastructure), you aim for compatibility instead of forced migration:

  • Adapter pattern. Make v2 the only real implementation. /v1/ stays as a thin translation layer: convert the v1 request to v2, call v2 logic, convert the response back. The v1 business logic is gone, but the v1 interface lives on.
  • Date-based versions (Stripe's approach). Each account is pinned to the API version from when it integrated. A transformation layer applies a chain of changes in reverse, so responses look the way that version expects. Stripe judged that maintaining transformations costs less than making every developer migrate.
  • Shadow traffic. Before switching a client, the load balancer sends each v1 request to v1 (the real response) and a copy to v2 (the shadow), and you compare the results in the background. Once they match consistently, switching is safe.
  • Per-client routing. As a last resort, route one large client (by a client id header) to a legacy cluster while everyone else uses v2. It's expensive, but cheaper than losing a major customer.
Strategy Risk to clients Maintenance cost Best for
Brownouts High Low Public or free APIs
Adapter Very low Medium Internal services, critical APIs
Transformation chain Very low High APIs that promise never to break (Stripe)
Shadowing None (verification only) Medium Proving v2 matches before switching

Key takeaways#

  • Offset pagination is simple and supports page numbers, but it drifts and slows on large, changing data.
  • Cursor pagination is the default for feeds: stable and fast at any depth, but forward and back only.
  • Use URL versioning by default, and bump the version only for breaking changes.
  • Retire versions gradually: deprecate, add Sunset headers, monitor, then remove.
  • For critical clients, keep old interfaces alive with an adapter over the new logic.

Found a mistake? Tell me on LinkedIn ↗

Further reading#