Shared from "Frontend Concepts Low Level" on Inkdown
Pagination is about fetching and displaying a subset of data at a time instead of loading everything at once. The core problem it solves: your dataset can have 10 items or 10 million — you can't render all of them in the DOM.
There are two fundamental models of pagination:
(page-1) * pageSize itemsThis is the classic "Page 1 of 10" model.
Or sometimes:
currentPage changes → new datapageSize changes → reset currentPage to 1, then fetchIf user is on page 5 and changes pageSize from 20 to 50, page 5 no longer makes sense. Always reset to page 1 when pageSize changes.
Instead of "give me page 3," it's "give me items after this cursor."
The cursor is an opaque token — the frontend doesn't need to understand it. It just passes it back to the API.
| Advantage | Explanation |
|---|---|
| No drift problem | If new items are inserted while user browses, offset pagination skips/duplicates items. Cursor pagination doesn't. |
| Performance | WHERE id > cursor is O(1) with an index. OFFSET 10000 scans and discards 10,000 rows. |
| Consistency | Works well with real-time data feeds. |
totalPages upfrontallData = [...allData, ...newPage]Place an invisible <div> at the end of your list. When it enters the viewport, trigger the next fetch.
This is way better than scroll event listeners because:
If you're on page 5 and delete the last item on that page → you're now on an empty page.
Fix: After deletion, check if current page has 0 items AND currentPage > 1 → go to currentPage - 1.
SELECT COUNT(*) on a 10M row table is slow. Options:
reltuples from pg_class)hasMore: boolean instead of exact total (cursor-based does this naturally)User clicks page 2, then page 3 quickly. Page 2's response arrives after page 3's → wrong data shown.
Fix 1 — Disable buttons while loading:
Fix 2 — Abort stale requests:
Fix 3 — Request ID tracking:
Show the new page's skeleton/loading state immediately. Don't keep showing old data. Users hate seeing stale content flash while "loading..."
Pagination state should be in the URL. Why:
Query params (most common):
Path segments (less common, mostly for blog/article pages):
Gotcha: Don't put pagination in URL hash (#page=3) — it doesn't trigger navigation events consistently and isn't sent to the server.
When you have 500 pages, you can't render [1][2][3]...[500]. You need a sliding window.
This is the exact logic behind every pagination component (MUI, Ant Design, shadcn — they all implement this).
These solve the same problem differently:
| Aspect | Pagination | Virtualization |
|---|---|---|
| Data loaded | Subset from server | All data in memory |
| DOM nodes | Only current page | Only visible rows |
| Use when | Dataset is huge (100K+) | Dataset fits in memory (~10K or less) |
| Scrollbar | Discrete pages | Continuous scroll |
| Libraries | Manual / API-driven | react-window, react-virtuoso, @tanstack/virtual |
You can also combine them: paginate to load chunks from server, then virtualize within each chunk. This is what tools like AG Grid and large data tables do.
When user is on page 1, prefetch page 2 in the background:
When mouse hovers over "Next" button, prefetch. This gives 200-500ms of head start:
Chat apps have a fundamentally different pagination model than typical list pagination. This section covers everything unique to chat-style interfaces.
Normal pagination = one direction (next page). Chat = two directions simultaneously:
Chat apps don't start at page 1. They start at the most recent messages. This is inverted from normal pagination.
The API returns 50 most recent messages. You render them reversed (oldest on top, newest on bottom) even though they came back newest-first.
This is the dominant pattern in chat. Instead of numeric page offsets, you use message IDs or timestamps as anchors.
before → messages older than this IDafter → messages newer than this IDaround → messages centered around this ID (used for "jump to message")This is the real architecture behind every chat app:
around CursorUser clicks a notification for a message from 3 months ago. You need to load context around it:
This returns ~25 messages before + ~25 after, centered on the target.
When user jumps to a message (via around), there's now a gap between the loaded window and previously loaded messages.
When user scrolls toward a gap, fetch the missing range:
The hardest part of chat pagination. When you prepend older messages (user scrolled up), the browser scroll position jumps. You need to preserve the user's perceived position.
requestAnimationFrame?The DOM hasn't updated yet when setMessages is called. requestAnimationFrame fires after the browser has painted the new content, so scrollHeight reflects the new messages.
initialTopMostItemIndex and scroll preservationscrollToItemscrollPadding and measurement APIsNot pagination per se, but closely related — inserting date headers between messages:
Date separators count toward your visual layout but NOT your API limit.
Threads (like Slack threads) are pagination within pagination:
Each thread has its own cursor state, independent of the parent channel.
| Pattern | Used By | Direction | Cursor Type |
|---|---|---|---|
| Offset/Limit | E-commerce, admin panels | Forward only | ?page=3 |
| Before/After | Discord, Slack, WhatsApp | Bi-directional | Message ID or timestamp |
| Around | Discord (jump-to-msg) | Centered | Message ID |
| Timestamp window | Slack | Bi-directional | Unix timestamp |
| WebSocket + API | Every real-time chat | Hybrid | N/A (push) + cursor |