---
title: Cursor Pagination for Real-Time Data
description: "Offset pagination breaks when flight prices change every minute. Cursor-based pagination keeps pages stable, fast, and correct even with volatile travel data."
canonical: https://nowah.xyz/blog/cursor-pagination-realtime-data
lastModified: "2026-08-07T08:10:31.072Z"
---

# Cursor Pagination for Real-Time Data

Offset pagination breaks when flight prices change every minute. Cursor-based pagination keeps pages stable, fast, and correct even with volatile travel data.

Flight prices change multiple times per minute across 900+ airlines. Hotel rooms sell out, cancellations open new availability, and dynamic pricing shifts rates based on demand. If you are paginating through this data with traditional offset-based pagination, you are going to have a bad time.

I watched a developer debug a phantom row issue for most of an afternoon. Their integration was paginating through flight search results and occasionally showing the same flight twice on consecutive pages. Sometimes flights disappeared entirely between page requests. The search results were not wrong — the data was genuinely changing between requests, and their pagination strategy could not handle it.

This is the fundamental [problem with](/blog/problem-with-travel-loyalty-programs) offset pagination on volatile data. And travel data is about as volatile as it gets.

## The offset pagination trap

![Illustration for this section](https://pics.nowah.xyz/website-media/developer-experience-005-img-1-offset-problem.webp)

Offset pagination works like this: request page 1 (skip 0, take 20), page 2 (skip 20, take 20), page 3 (skip 40, take 20), and so on. Each page is defined by a numeric position in the result set.

This is fine when the result set is static. Library catalogs, employee directories, product inventories that update once a day. You can paginate through them safely because the data does not move between requests.

Travel search results are not static. Between your request for page 1 and your request for page 2, a fare update inserts a new result at position 15. Everything after position 15 shifts down by one. The result that was at position 20 (the last item on page 1) is now at position 21 (the first item on page 2). You see it twice.

Or the opposite happens. A fare expires and the result at position 18 disappears. Everything shifts up. The result that was at position 21 (first item on page 2) moves to position 20 and appears on page 1. You never see the result that was at position 20 because it shifted to position 19, which was already served on page 1. You miss it entirely.

These are not edge cases. With 900+ airlines updating fares constantly, multiple changes happen within the seconds between page requests. The phantom row problem is not a theoretical concern for travel APIs — it is a daily reality.

## How cursor pagination solves this

Cursor pagination replaces the numeric offset with an opaque token that points to a specific position in the result set. Instead of "skip 20," you say "give me results after this cursor."

The cursor encodes enough information to identify exactly where the previous page ended\. Typically this is a combination of the sort key and the unique record ID\. When the server receives the cursor, it uses an indexed \`WHERE\` clause to fetch results after that exact position, regardless of what has changed elsewhere in the result set\.

If a new result gets inserted before the cursor position, it does not affect the next page. The cursor still points to the same record, and results after that record are returned correctly. If a result before the cursor is deleted, same thing — the cursor is anchored to a specific record, not a numeric position.

Our API returns two fields for pagination: \`nextCursor\` \(the opaque token for the next page\) and \`hasMore\` \(a boolean indicating whether more results exist\)\. The client does not need to know anything about how the cursor is encoded\. They receive it, store it, and send it back when they want the next page\.

```
{
 "success": true,
 "data": {
 "offers": [...],
 "nextCursor": "eyJzIjoicHJpY2UiLCJpZCI6ImZsdF9hYmMxMjMifQ==",
 "hasMore": true
 }
}
```

The cursor is intentionally opaque. Developers cannot construct cursors manually, which prevents a class of bugs where someone tries to build a cursor from client-side state and gets it wrong. If you want the next page, use the cursor we gave you. If you want to start over, omit the cursor. There is no third option, and that constraint is a feature.

## Designing cursor tokens for travel data

![Supporting diagram](https://pics.nowah.xyz/website-media/developer-experience-005-img-2-cursor-token.webp)

The cursor token needs to encode two things: the sort key value and the unique ID of the last record on the current page.

For flight search results sorted by price, the cursor might encode \`\{ sort: "price", value: 14999, id: "flt\_abc123" \}\`\. The server uses this to construct a query like "find results where price \> 14999, or price = 14999 and ID \> flt\_abc123\." The second condition handles ties — when multiple flights have the same price, the unique ID provides a stable tiebreaker\.

We base64-encode the cursor to make it opaque and include a version prefix so we can change the encoding format in the future without breaking existing cursors. Old cursors gracefully degrade — if a cursor format is no longer supported, the server returns an error suggesting the client start a fresh search.

One important design decision: cursors should not leak internal implementation details. Some APIs encode database primary keys or internal sort values in their cursors. If those change (a database migration, a sort algorithm update), all outstanding cursors break. We encode logical values (price, offer ID) rather than physical values (row ID, page number) so the cursor remains valid across internal changes.

## Performance comparison

Offset pagination degrades as the offset grows\. On most databases, \`SKIP 10000 LIMIT 20\` requires the database to scan and discard 10,000 rows before returning the 20 you asked for\. The deeper you paginate, the slower it gets\. On large result sets, page 500 is dramatically slower than page 1\.

Cursor pagination maintains constant time regardless of depth\. The \`WHERE id \> cursor\_id LIMIT 20\` query uses an index seek, which performs the same whether the cursor points to the 20th record or the 20,000th\. Page 500 is exactly as fast as page 1\.

For travel search results where developers might paginate through hundreds of offers looking for the right option, this performance difference matters. We have seen integrations that paginate deeply through hotel results in specific cities — a search for hotels in Tokyo might return 500+ options, and developers building comparison tools want to process all of them. Offset pagination would make the later pages painfully slow. Cursor pagination keeps every page fast.

## Handling expired records mid-pagination

Travel offers expire. A flight offer that existed when you fetched page 1 might be gone by the time you fetch page 5. The cursor still works — it points to a position in the sort order, and if the record at that position has disappeared, the server starts from the next available record.

But this raises a user experience question: should we tell the client that some records from their previous pages have expired? We include a \`staleCount\` field in the response metadata that indicates how many records from the cursor's neighborhood have changed since the cursor was issued\. This is not an exact count of invalidated results, but it gives clients a signal that the data is shifting and they might want to restart the search if freshness matters\.

For AI agents paginating through results, we recommend completing the pagination quickly and then re-validating specific offers of interest with a fresh API call before proceeding to booking. Cursor pagination gives them stable pages for processing, and the fresh validation ensures they book at current prices.

## Migration guide

If you are currently using offset pagination and want to switch to cursors, here is the practical path.

Support both mechanisms simultaneously during the transition\. Accept either \`page\` and \`limit\` parameters \(offset\) or a \`cursor\` parameter \(cursor\)\. When a cursor is provided, use cursor\-based logic\. When only page/limit is provided, fall back to offset logic\.

Document the new cursor-based approach as the recommended method and mark offset pagination as deprecated with a sunset date. Update your SDKs to use cursors by default. Communicate the change in your changelog and migration guide.

The cursor\-based endpoint should return the same response shape as the offset\-based one, with the addition of \`nextCursor\` and \`hasMore\` fields\. Clients can switch by simply using the new pagination fields instead of incrementing a page number\.

Set a sunset date for offset pagination that gives developers enough time to migrate. We recommend at least six months. Monitor usage of offset parameters and reach out to developers who are still using them as the sunset date approaches.

Cursor pagination is not more complex to implement than offset pagination. It is different, and the initial unfamiliarity can feel like complexity. But once it is in place, it is more correct, more performant, and more resilient to the data volatility that defines travel. The upfront effort pays for itself the first time a developer does not file a bug report about phantom rows.

---

Nowah is an AI travel agent that searches and books real flights and hotels through conversation — no filters, no thirty open tabs. [Plan your next trip](https://app.nowah.xyz).
