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-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:
- Users need numbered navigation: "Go to page 4" or "Showing 1–20 of 5,000". A cursor only knows next and previous.
- The data is mostly static, like a list of countries or historical records, so nothing shifts.
- 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 (), 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-Versionto 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)#
- Deprecate. Announce v2 and mark v1 deprecated in the docs.
- Sunset header. Add a
Sunsetheader (the shutdown date) and a link to the migration guide on every v1 response, so clients' automated tooling notices even if nobody reads email. - Brownouts. Briefly make v1 fail on purpose (return
410 Goneor503) 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. - 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.
- 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
Sunsetheaders, monitor, then remove. - For critical clients, keep old interfaces alive with an adapter over the new logic.