This guide helps you migrate from offset-based pagination to the new cursor-based pagination system.
- Now: Both pagination methods supported
- 2026-12-31: Offset-based pagination will be sunset
- Action Required: Migrate your clients before December 31, 2026
GET /users?page=2&page_size=20Response:
{
"data": [...],
"page": 2,
"pageSize": 20,
"totalCount": 150
}GET /users?limit=20&cursor=base64_encodedResponse:
{
"data": [...],
"pagination": {
"cursor": "base64_encoded_next_cursor",
"hasMore": true,
"totalCount": 150,
"limit": 20
}
}- Stability: Handles concurrent inserts/deletes without missing records
- Performance: O(1) query time regardless of page number (no OFFSET overhead)
- Data Integrity: No duplicate records when paginating through constantly updated data
async function fetchUsers(page = 1, pageSize = 20) {
const response = await fetch(
`/api/users?page=${page}&page_size=${pageSize}`
);
const data = await response.json();
return {
users: data.data,
currentPage: data.page,
totalPages: Math.ceil(data.totalCount / pageSize),
totalCount: data.totalCount
};
}async function fetchUsers(cursor = null, limit = 20) {
const params = new URLSearchParams();
params.append('limit', limit);
if (cursor) {
params.append('cursor', cursor);
}
const response = await fetch(`/api/users?${params}`);
const data = await response.json();
return {
users: data.data,
cursor: data.pagination.cursor,
hasMore: data.pagination.hasMore,
totalCount: data.pagination.totalCount
};
}// Render page numbers: 1 2 3 4 5
async function goToPage(page) {
const result = await fetchUsers(page, 20);
renderUsers(result.users);
renderPagination(result.currentPage, result.totalPages);
}// Render: Previous | Next (simpler UI)
let currentCursor = null;
async function loadMore() {
const result = await fetchUsers(currentCursor, 20);
renderUsers(result.users);
currentCursor = result.cursor;
if (!result.hasMore) {
disableLoadMoreButton();
}
}
async function loadPrevious() {
// For previous, you need to maintain previous cursors
// OR reload from the beginning
currentCursor = null;
await loadMore();
}Cursors may become invalid if data changes. Handle gracefully:
async function fetchWithFallback(cursor, limit = 20) {
try {
return await fetchUsers(cursor, limit);
} catch (error) {
if (error.status === 410 || error.message.includes('Invalid cursor')) {
console.warn('Cursor expired, reloading from start');
return await fetchUsers(null, limit); // Start over
}
throw error;
}
}describe('Cursor Pagination', () => {
it('should fetch first page without cursor', async () => {
const result = await fetchUsers(null, 20);
expect(result.users).toHaveLength(20);
expect(result.hasMore).toBe(true);
expect(result.cursor).toBeDefined();
});
it('should fetch next page with cursor', async () => {
const first = await fetchUsers(null, 20);
const second = await fetchUsers(first.cursor, 20);
expect(second.users).toHaveLength(20);
expect(second.users[0].id).not.toBe(first.users[0].id);
});
it('should handle invalid cursor gracefully', async () => {
try {
await fetchUsers('invalid-cursor', 20);
fail('Should throw error');
} catch (error) {
expect(error.status).toBe(400);
expect(error.message).toContain('Invalid cursor');
}
});
it('should work with concurrent data changes', async () => {
const first = await fetchUsers(null, 50);
// Simulate data insertion
await insertNewUser();
const second = await fetchUsers(first.cursor, 50);
// Should not have duplicates
const seenIds = new Set();
[...first.users, ...second.users].forEach(user => {
expect(seenIds.has(user.id)).toBe(false);
seenIds.add(user.id);
});
});
});Monitor the deprecation header:
async function fetchUsers(cursor = null, limit = 20) {
const response = await fetch(`/api/users?limit=${limit}&cursor=${cursor}`);
// Check for deprecation warning
if (response.headers.get('Deprecation') === 'true') {
const sunset = response.headers.get('Sunset');
console.warn(`API endpoint deprecated. Sunset date: ${sunset}`);
}
return await response.json();
}Both pagination methods work:
# Old method still works (will show deprecation warning)
GET /users?page=2&page_size=20
# New method
GET /users?limit=20&cursor=base64_encoded# Old method will return 410 Gone
GET /users?page=2&page_size=20
# Response: 410 Gone - Offset pagination sunset 2026-12-31
# New method continues to work
GET /users?limit=20&cursor=base64_encodedMigration example with filters:
// Old
GET /users?page=2&page_size=20&filter[status]=active&search=john
// New
GET /users?limit=20&cursor=base64&status=active&search=johnA: Yes, until 2026-12-31. After that date, only cursor pagination is supported.
A: Start pagination from the beginning (without cursor). Cursor expiration is rare but can happen if data changes significantly.
A: Cursor pagination doesn't support going backward efficiently. Either:
- Reload from the beginning
- Maintain a stack of cursors (memory-intensive)
- Use filters to narrow down and reload
A: No. Cursor pagination requires sequential traversal. For random access, load from start or use filters to narrow scope.
A: Use the totalCount in the pagination object:
const result = await fetchUsers(cursor);
console.log(`Showing results of ${result.totalCount} total users`);A: Cursor pagination performs much better:
- Page 1 (offset 0): ~10ms
- Page 1000 (offset 20,000): ~100ms with offset, ~10ms with cursor
Track usage of old vs new API:
# Monitor logs for old API usage
grep 'page_size=' access.log | wc -l
# Set up alerts if old API usage > 10% after 2026-06-01For issues during migration:
- Check the Pagination Guide
- Review test cases in
/tests/users.e2e.spec.ts - Contact the backend team
- Now: Update client code to use cursor pagination
- 2026-06-01: Remove old pagination code from client
- 2026-12-15: Final warning - ensure all clients migrated
- 2026-12-31: Old API returns 410 Gone