Feeds — Pagination & dedup
Every page of a feed is a snapshot, and snapshots move. The pagination
block controls how pages compose into a stable scroll: cursor shape, dedup
rules, and how many recent ids the cursor remembers.
The config surface
pageSize
How many items to return per call. Tier-clamped at the service layer, so an
unreasonable value is capped silently — the response's meta.pageSize
reports the effective cap.
cursor
The cursor carries the current scroll position:
'rank+id'(default) — encodes the last item's score and id. Stable under ties and under late-arriving rows with identical scores.'rank'— encodes just the last score. Smaller cursor, but prone to duplication when scores tie.
Most feeds want 'rank+id'. Use 'rank' only when score is guaranteed unique
(e.g. pure recency with a timestamp column).
dedup
Dedup is a discriminated union with three shapes:
within | Meaning |
|---|---|
'session' (default) | Remember ids across pages, up to seenWindow. |
'page' | Only de-dup within a single response. |
{ window: N } | Remember the last N ids — useful for "no author repeats in last 20 items." |
seenWindow
How many recent ids the cursor is allowed to remember. Default 200. Larger
windows dedup better at the cost of bigger cursors. The signed cursor is
still small (~a few hundred bytes at seenWindow: 200), but if you're
scrolling into the thousands, drop it to 50 or use { within: { window: 50 } }.
excludeIds
A context path that resolves to ids to exclude from the result (e.g. the seed record in a "more like this" feed). Filtered after ranking.
Paginating from the client
The evolved flag
FeedPage.evolved goes true when the supplied context's hash changed
since the last page — it tells the UI "your context is different from the
one this cursor was minted against, you probably want a fresh fetch."
The React hook watches context for you:
Sweet spots by feed type
| Feed type | pageSize | dedup | cursor |
|---|---|---|---|
| Discover (algorithmic) | 12 | { by: 'sourceRowId', within: 'session' } | 'rank+id' |
| Latest (chronological) | 20 | { by: 'sourceRowId' } | 'rank' (recency is unique) |
| Related (more like this) | 6 | { by: 'sourceRowId' } + excludeIds | 'rank+id' |
| Editorial / curated | 8 | { by: 'sourceRowId', within: 'page' } | 'rank+id' |
Gotchas
- Changing the feed config invalidates outstanding cursors. A cursor minted against
seenWindow: 200deserialized againstseenWindow: 50is rejected with400 invalid_cursor. Your UI should fall back to a clean page fetch. - Cursors are signed. Modifying them on the client is detected and rejected. Don't try to decode them; treat them as opaque.
{ within: 'page' }+ smallpageSizeis essentially "no dedup." If you want cross-page dedup, stay on'session'.