← Stackzilla Blog

Cursor Pagination: Keeping Feeds Stable as Data Changes

Published October 24, 2026 · 3 min read · API design, databases, pagination, backend

Understand when offset pagination becomes unreliable and how a stable cursor keeps users moving through a changing dataset.

Pagination looks simple until the list changes between requests. A user loads the first page of notifications, new rows arrive, and the second page repeats items they already saw. Deletions can produce the opposite problem: records disappear from the user's traversal. Offset pagination asks the database to skip a number of rows. Cursor pagination asks it to continue after a particular position in a defined ordering. Neither is universally better, but the distinction matters for feeds, audit logs, and other growing datasets. ## Give the list a total order A cursor needs an unambiguous place to resume. Ordering by creation time alone is insufficient when multiple records share a timestamp. Add a unique tie-breaker, such as an identifier. For a descending feed ordered by creation time and identifier, the next page selects rows whose pair is less than the last pair returned. Fetch one extra row to determine whether another page exists. Use a matching index and check the query plan on representative data. Choose ordering values that do not change unexpectedly. If a record's sort key is updated during traversal, it can move across the cursor boundary. A cursor does not magically provide a snapshot of a changing database. Snapshot consistency is a separate requirement. ## Treat cursors as input, not permission A cursor can encode the last timestamp, identifier, filter settings, and a format version. Encoding JSON as base64 makes it convenient to transport, not secure. Validate every field and cap page size. Sign the cursor if the design requires protection against client modification. Always reapply tenant and access-control filters. A user holding a cursor from another account must not gain access to that account's records. If filters change, either reject the old cursor clearly or define exactly how the new search starts. Keep the response contract predictable: items, an optional next cursor, and a clear end condition. Avoid making clients infer completion from a particular number of results when filtering or deletion can affect page length. ## Know what you are giving up Cursor pagination is well suited to continuing forward or backward. It is less convenient for jumping directly to page 87 or displaying an exact page count. Administrative tables with small, relatively static datasets may be better served by offsets. Test concurrent inserts, equal timestamps, deleted boundary records, empty pages, and malformed cursors. Also test the first and final page with the same authorization rules as every middle page. Good pagination is a data contract, not just a pair of buttons. Pick the model that matches how people navigate the information, then make its consistency limits explicit. ## Further reading - [PostgreSQL: LIMIT and OFFSET](https://www.postgresql.org/docs/current/queries-limit.html) - [GitHub: Using pagination in the GraphQL API](https://docs.github.com/en/graphql/guides/using-pagination-in-the-graphql-api)

Read the full article on Stackzilla →